capacium-models 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capacium_models/__init__.py +66 -0
- capacium_models/keys.py +123 -0
- capacium_models/labels.py +75 -0
- capacium_models/models.py +389 -0
- capacium_models/search.py +141 -0
- capacium_models/trust.py +211 -0
- capacium_models-0.5.0.dist-info/METADATA +99 -0
- capacium_models-0.5.0.dist-info/RECORD +11 -0
- capacium_models-0.5.0.dist-info/WHEEL +5 -0
- capacium_models-0.5.0.dist-info/licenses/LICENSE +21 -0
- capacium_models-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared domain models for the Capacium ecosystem.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from capacium_models import Listing, TrustState, TrustMachine
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
MCPClient,
|
|
9
|
+
MCPMetadata,
|
|
10
|
+
TrustTransition,
|
|
11
|
+
Listing,
|
|
12
|
+
Publisher,
|
|
13
|
+
Category,
|
|
14
|
+
Subcategory,
|
|
15
|
+
TagCount,
|
|
16
|
+
Collection,
|
|
17
|
+
ClaimRequest,
|
|
18
|
+
VerificationRecord,
|
|
19
|
+
SearchQuery,
|
|
20
|
+
)
|
|
21
|
+
from .trust import TrustState, TrustMachine, TRUST_BADGES, get_trust_badge
|
|
22
|
+
from .keys import (
|
|
23
|
+
LICENSE_PUBLIC_KEYS,
|
|
24
|
+
UnknownLicenseKid,
|
|
25
|
+
get_license_public_key,
|
|
26
|
+
known_kids,
|
|
27
|
+
)
|
|
28
|
+
from .labels import (
|
|
29
|
+
RESOURCE_KIND_LABELS,
|
|
30
|
+
OPERATOR_TYPE_LABELS,
|
|
31
|
+
INSTALL_STATUS_LABELS,
|
|
32
|
+
get_kind_label,
|
|
33
|
+
get_operator_type_label,
|
|
34
|
+
get_install_status_label,
|
|
35
|
+
)
|
|
36
|
+
from .search import ExchangeSearch
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"MCPClient",
|
|
40
|
+
"MCPMetadata",
|
|
41
|
+
"TrustState",
|
|
42
|
+
"TrustTransition",
|
|
43
|
+
"TrustMachine",
|
|
44
|
+
"Listing",
|
|
45
|
+
"Publisher",
|
|
46
|
+
"Category",
|
|
47
|
+
"Subcategory",
|
|
48
|
+
"TagCount",
|
|
49
|
+
"Collection",
|
|
50
|
+
"ClaimRequest",
|
|
51
|
+
"VerificationRecord",
|
|
52
|
+
"SearchQuery",
|
|
53
|
+
"ExchangeSearch",
|
|
54
|
+
"TRUST_BADGES",
|
|
55
|
+
"get_trust_badge",
|
|
56
|
+
"RESOURCE_KIND_LABELS",
|
|
57
|
+
"OPERATOR_TYPE_LABELS",
|
|
58
|
+
"INSTALL_STATUS_LABELS",
|
|
59
|
+
"get_kind_label",
|
|
60
|
+
"get_operator_type_label",
|
|
61
|
+
"get_install_status_label",
|
|
62
|
+
"LICENSE_PUBLIC_KEYS",
|
|
63
|
+
"UnknownLicenseKid",
|
|
64
|
+
"get_license_public_key",
|
|
65
|
+
"known_kids",
|
|
66
|
+
]
|
capacium_models/keys.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""License public-key registry for capacium-models consumers.
|
|
2
|
+
|
|
3
|
+
Consumers verify license tokens (issued by capacium-exchange `LicenseService`)
|
|
4
|
+
using Ed25519. The matching PRIVATE key lives ONLY in envctl on prod, under:
|
|
5
|
+
|
|
6
|
+
envctl get capacium-exchange CAPACIUM_LICENSE_SIGNING_KEY # 64-hex private
|
|
7
|
+
envctl get capacium-exchange CAPACIUM_LICENSE_SIGNING_KID # current kid
|
|
8
|
+
|
|
9
|
+
This module holds the corresponding PUBLIC keys, indexed by `kid` (key
|
|
10
|
+
identifier as embedded in license token payloads). Consumers use these to
|
|
11
|
+
verify signatures locally without round-tripping to the Exchange.
|
|
12
|
+
|
|
13
|
+
Key rotation lifecycle for a given kid:
|
|
14
|
+
- Active: issues new tokens; PRIVATE held in envctl
|
|
15
|
+
- Retired: no longer issues NEW tokens; OLD tokens still validate
|
|
16
|
+
(entry stays in this registry until last token expires)
|
|
17
|
+
- Removed: entry deleted from registry; OLD tokens fail validation
|
|
18
|
+
|
|
19
|
+
To add a new kid: append to LICENSE_PUBLIC_KEYS, bump the package version
|
|
20
|
+
(minor bump — additive); ship as a new capacium-models release. NEVER
|
|
21
|
+
remove an entry whose tokens may still be in circulation.
|
|
22
|
+
|
|
23
|
+
DECISION-001 reference: license-signing keypair is DISTINCT from
|
|
24
|
+
capability-signing keypair. kid namespace MUST start with `lic-`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Dict, List
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── License public-key registry ───────────────────────────────────────────
|
|
33
|
+
#
|
|
34
|
+
# Format: dict[kid -> 64-char lowercase hex] (32 bytes Ed25519 public key)
|
|
35
|
+
#
|
|
36
|
+
# Each entry MUST match the private key currently set as
|
|
37
|
+
# CAPACIUM_LICENSE_SIGNING_KEY in envctl on capacium-prod, under the
|
|
38
|
+
# matching kid CAPACIUM_LICENSE_SIGNING_KID.
|
|
39
|
+
|
|
40
|
+
LICENSE_PUBLIC_KEYS: Dict[str, str] = {
|
|
41
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
42
|
+
# kid: lic-2026-q2-01
|
|
43
|
+
# Added: 2026-06-03 (initial v2 launch — corresponds to the FIRST
|
|
44
|
+
# Ed25519 keypair generated on maintainer workstation, private uploaded
|
|
45
|
+
# to envctl on capacium-prod)
|
|
46
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
47
|
+
"lic-2026-q2-01": "8e5e33e1ab3676d239a1f89cded6afca9aca0e8ba24c3b4b50ad4b71ef84e978",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UnknownLicenseKid(KeyError):
|
|
55
|
+
"""Raised when a license token's `kid` is not in the public-key registry.
|
|
56
|
+
|
|
57
|
+
Consumers MUST reject the token in this case (treat as invalid signature).
|
|
58
|
+
Receiving this exception is the signal to upgrade `capacium-models` to a
|
|
59
|
+
newer version that includes the missing kid.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_license_public_key(kid: str) -> str:
|
|
64
|
+
"""Return the 64-char hex public key for a license kid.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
kid: Key identifier as embedded in license token payload.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
64-char lowercase hex string representing the Ed25519 public key.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
UnknownLicenseKid: when `kid` is not in `LICENSE_PUBLIC_KEYS`, OR
|
|
74
|
+
when its registered value is not a valid 64-char hex string
|
|
75
|
+
(e.g., still a placeholder, malformed registry entry).
|
|
76
|
+
Consumer code MUST reject the token in this case.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
pubkey = LICENSE_PUBLIC_KEYS[kid]
|
|
80
|
+
except KeyError as e:
|
|
81
|
+
raise UnknownLicenseKid(
|
|
82
|
+
f"kid={kid!r} not in LICENSE_PUBLIC_KEYS. "
|
|
83
|
+
f"Known kids: {known_kids()}. "
|
|
84
|
+
f"Upgrade capacium-models to support newer kids."
|
|
85
|
+
) from e
|
|
86
|
+
_validate_hex(kid, pubkey)
|
|
87
|
+
return pubkey
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def known_kids() -> List[str]:
|
|
91
|
+
"""Return all registered kids, sorted newest-first (lexicographic reverse).
|
|
92
|
+
|
|
93
|
+
Naming convention `lic-YYYY-qN-NN` makes lex reverse equal to
|
|
94
|
+
chronological reverse.
|
|
95
|
+
"""
|
|
96
|
+
return sorted(LICENSE_PUBLIC_KEYS.keys(), reverse=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_hex(kid: str, pubkey: str) -> None:
|
|
103
|
+
"""Validate that `pubkey` is a 64-char lowercase hex string.
|
|
104
|
+
|
|
105
|
+
Raises UnknownLicenseKid (not ValueError) so consumers handling kid
|
|
106
|
+
lookup errors have a single exception type to catch.
|
|
107
|
+
"""
|
|
108
|
+
if not isinstance(pubkey, str):
|
|
109
|
+
raise UnknownLicenseKid(
|
|
110
|
+
f"kid={kid!r} has non-string pubkey of type {type(pubkey).__name__}"
|
|
111
|
+
)
|
|
112
|
+
if len(pubkey) != 64:
|
|
113
|
+
raise UnknownLicenseKid(
|
|
114
|
+
f"kid={kid!r} pubkey length is {len(pubkey)}, expected 64 hex chars. "
|
|
115
|
+
f"Likely placeholder — registry entry must be filled in before release."
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
int(pubkey, 16)
|
|
119
|
+
except ValueError as e:
|
|
120
|
+
raise UnknownLicenseKid(
|
|
121
|
+
f"kid={kid!r} pubkey is not valid hex: {e}. "
|
|
122
|
+
f"Registry entry may still contain a placeholder."
|
|
123
|
+
) from e
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Canonical label registries for all Capacium surfaces.
|
|
2
|
+
|
|
3
|
+
These dicts serve as single sources of truth — all UIs, badges, APIs, and CLI
|
|
4
|
+
surfaces derive their display labels from these exactly maps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
RESOURCE_KIND_LABELS = {
|
|
8
|
+
"skill": "Skill",
|
|
9
|
+
"mcp-server": "MCP Server",
|
|
10
|
+
"bundle": "Bundle",
|
|
11
|
+
"tool": "Tool",
|
|
12
|
+
"prompt": "Prompt",
|
|
13
|
+
"template": "Template",
|
|
14
|
+
"workflow": "Workflow",
|
|
15
|
+
"connector-pack": "Connector Pack",
|
|
16
|
+
"operator": "Operator",
|
|
17
|
+
"checkpoint": "Checkpoint",
|
|
18
|
+
"policy": "Policy",
|
|
19
|
+
"resource": "Resource",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
OPERATOR_TYPE_LABELS = {
|
|
23
|
+
"ai": "AI",
|
|
24
|
+
"human": "Human",
|
|
25
|
+
"hybrid": "Hybrid",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
INSTALL_STATUS_LABELS = {
|
|
29
|
+
"not_installed": "Not Installed",
|
|
30
|
+
"installing": "Installing",
|
|
31
|
+
"installed": "Installed",
|
|
32
|
+
"failed": "Failed",
|
|
33
|
+
"update_available": "Update Available",
|
|
34
|
+
"removed": "Removed",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
TRUST_BADGES = {
|
|
38
|
+
"discovered": "Discovered",
|
|
39
|
+
"pending_review": "Pending Review",
|
|
40
|
+
"verified": "Verified",
|
|
41
|
+
"signed": "Signed",
|
|
42
|
+
"deprecated": "Deprecated",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_kind_label(kind: str) -> str:
|
|
47
|
+
"""Return the human-readable label for a capability kind.
|
|
48
|
+
|
|
49
|
+
Falls back to the raw string if the kind is unknown.
|
|
50
|
+
"""
|
|
51
|
+
return RESOURCE_KIND_LABELS.get(kind, kind)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_operator_type_label(op_type: str) -> str:
|
|
55
|
+
"""Return the human-readable label for an operator type.
|
|
56
|
+
|
|
57
|
+
Falls back to the raw string if the type is unknown.
|
|
58
|
+
"""
|
|
59
|
+
return OPERATOR_TYPE_LABELS.get(op_type, op_type)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_install_status_label(status: str) -> str:
|
|
63
|
+
"""Return the human-readable label for an install status.
|
|
64
|
+
|
|
65
|
+
Falls back to the raw string if the status is unknown.
|
|
66
|
+
"""
|
|
67
|
+
return INSTALL_STATUS_LABELS.get(status, status)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_trust_badge(state: str) -> str:
|
|
71
|
+
"""Return the human-readable trust badge label for a trust state.
|
|
72
|
+
|
|
73
|
+
Falls back to the raw string if the state is unknown.
|
|
74
|
+
"""
|
|
75
|
+
return TRUST_BADGES.get(state, state)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Exchange Core domain models.
|
|
2
|
+
|
|
3
|
+
Defines the central data structures for the Capacium Exchange:
|
|
4
|
+
Listing, MCPMetadata, Publisher, Category, Subcategory, TagCount,
|
|
5
|
+
Collection, ClaimRequest, VerificationRecord, TrustTransition, SearchQuery.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCPClient(Enum):
|
|
17
|
+
CLAUDE_DESKTOP = "claude-desktop"
|
|
18
|
+
CLAUDE_CODE = "claude-code"
|
|
19
|
+
CURSOR = "cursor"
|
|
20
|
+
OPENCODE = "opencode"
|
|
21
|
+
CODEX = "codex"
|
|
22
|
+
CONTINUE = "continue"
|
|
23
|
+
WINDSURF = "windsurf"
|
|
24
|
+
GENERIC = "generic-mcp-client"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MCPMetadata:
|
|
29
|
+
supported_clients: List[str] = field(default_factory=list)
|
|
30
|
+
transport: str = "stdio"
|
|
31
|
+
runtime: Optional[str] = None
|
|
32
|
+
hosted: bool = False
|
|
33
|
+
auth_model: Optional[str] = None
|
|
34
|
+
required_env_vars: List[str] = field(default_factory=list)
|
|
35
|
+
permission_scopes: List[str] = field(default_factory=list)
|
|
36
|
+
health_endpoint: Optional[str] = None
|
|
37
|
+
health_status: Optional[str] = None
|
|
38
|
+
last_health_check: Optional[str] = None
|
|
39
|
+
setup_command: Optional[str] = None
|
|
40
|
+
documentation_url: Optional[str] = None
|
|
41
|
+
command_examples: List[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
44
|
+
return asdict(self)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MCPMetadata":
|
|
48
|
+
if data is None:
|
|
49
|
+
return cls()
|
|
50
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
51
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
52
|
+
return cls(**filtered)
|
|
53
|
+
|
|
54
|
+
def to_json(self) -> str:
|
|
55
|
+
return json.dumps(self.to_dict())
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_json(cls, text: str) -> "MCPMetadata":
|
|
59
|
+
if not text:
|
|
60
|
+
return cls()
|
|
61
|
+
return cls.from_dict(json.loads(text))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class TrustTransition:
|
|
66
|
+
from_state: str
|
|
67
|
+
to_state: str
|
|
68
|
+
reason: str = ""
|
|
69
|
+
triggered_by: str = "system"
|
|
70
|
+
timestamp: str = ""
|
|
71
|
+
|
|
72
|
+
def __post_init__(self):
|
|
73
|
+
if not self.timestamp:
|
|
74
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
77
|
+
return asdict(self)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TrustTransition":
|
|
81
|
+
return cls(**{k: v for k, v in data.items() if k in {
|
|
82
|
+
"from_state", "to_state", "reason", "triggered_by", "timestamp"
|
|
83
|
+
}})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Listing:
|
|
88
|
+
id: str = ""
|
|
89
|
+
canonical_name: str = ""
|
|
90
|
+
package_type: str = "skill"
|
|
91
|
+
|
|
92
|
+
canonical_source_url: str = ""
|
|
93
|
+
source_urls: List[str] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
short_description: str = ""
|
|
96
|
+
long_description: str = ""
|
|
97
|
+
|
|
98
|
+
trust_state: str = "discovered"
|
|
99
|
+
trust_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
primary_category: str = "developer-tools"
|
|
102
|
+
subcategory: Optional[str] = None
|
|
103
|
+
tags: List[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
publisher_id: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
target_frameworks: List[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
installability: str = "local-install"
|
|
110
|
+
install_reference: Optional[str] = None
|
|
111
|
+
|
|
112
|
+
license: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
maturity: str = "experimental"
|
|
115
|
+
|
|
116
|
+
discovered_at: str = ""
|
|
117
|
+
indexed_at: Optional[str] = None
|
|
118
|
+
updated_at: str = ""
|
|
119
|
+
|
|
120
|
+
mcp_metadata: Optional[Dict[str, Any]] = None
|
|
121
|
+
|
|
122
|
+
installed_capability_id: Optional[int] = None
|
|
123
|
+
|
|
124
|
+
# -- GitHub Enrichment --
|
|
125
|
+
github_owner: Optional[str] = None
|
|
126
|
+
github_repo: Optional[str] = None
|
|
127
|
+
github_stars: int = 0
|
|
128
|
+
github_forks: int = 0
|
|
129
|
+
github_watchers: int = 0
|
|
130
|
+
github_license: Optional[str] = None
|
|
131
|
+
github_topics: List[str] = field(default_factory=list)
|
|
132
|
+
github_language: Optional[str] = None
|
|
133
|
+
github_description: Optional[str] = None
|
|
134
|
+
github_homepage: Optional[str] = None
|
|
135
|
+
github_default_branch: Optional[str] = None
|
|
136
|
+
github_created_at: Optional[str] = None
|
|
137
|
+
github_pushed_at: Optional[str] = None
|
|
138
|
+
github_updated_at: Optional[str] = None
|
|
139
|
+
|
|
140
|
+
# -- Document Model v2 --
|
|
141
|
+
source_repo: Optional[str] = None # e.g. "anthropics/skills"
|
|
142
|
+
source_path: str = "/" # e.g. "skills/ai-ml/3d-cv-labeling"
|
|
143
|
+
repo_type: str = "unknown" # single_skill | multi_skill | collection | mcp_server | unknown
|
|
144
|
+
skill_path: Optional[str] = None # path to SKILL.md within repo
|
|
145
|
+
skill_name: Optional[str] = None # parsed from SKILL.md frontmatter
|
|
146
|
+
skill_compatibility: Optional[Dict[str, Any]] = None # from SKILL.md
|
|
147
|
+
skill_metadata: Optional[Dict[str, Any]] = None # from SKILL.md
|
|
148
|
+
|
|
149
|
+
# -- Manifest --
|
|
150
|
+
manifest_raw: Optional[str] = None # raw capability.yaml content
|
|
151
|
+
manifest_version: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
# -- Capability IR --
|
|
154
|
+
capability_ir: Optional[Dict[str, Any]] = None # framework-agnostic IR (JSONB)
|
|
155
|
+
adaptation_targets: List[str] = field(default_factory=list) # ["mcp-server", "a2a-agent"]
|
|
156
|
+
|
|
157
|
+
# -- Capacium Trust --
|
|
158
|
+
fingerprint: Optional[str] = None
|
|
159
|
+
signature_value: Optional[str] = None
|
|
160
|
+
signature_public_key: Optional[str] = None
|
|
161
|
+
|
|
162
|
+
def __post_init__(self):
|
|
163
|
+
if not self.id:
|
|
164
|
+
self.id = str(uuid.uuid4())
|
|
165
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
166
|
+
if not self.discovered_at:
|
|
167
|
+
self.discovered_at = now
|
|
168
|
+
if not self.updated_at:
|
|
169
|
+
self.updated_at = now
|
|
170
|
+
|
|
171
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
172
|
+
return asdict(self)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Listing":
|
|
176
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
177
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
178
|
+
for list_field in ("source_urls", "tags", "target_frameworks", "trust_history", "github_topics", "adaptation_targets"):
|
|
179
|
+
if list_field in filtered and isinstance(filtered[list_field], str):
|
|
180
|
+
try:
|
|
181
|
+
filtered[list_field] = json.loads(filtered[list_field])
|
|
182
|
+
except (json.JSONDecodeError, TypeError):
|
|
183
|
+
filtered[list_field] = []
|
|
184
|
+
for dict_field in ("mcp_metadata", "skill_compatibility", "skill_metadata", "capability_ir"):
|
|
185
|
+
if dict_field in filtered and isinstance(filtered[dict_field], str):
|
|
186
|
+
try:
|
|
187
|
+
filtered[dict_field] = json.loads(filtered[dict_field])
|
|
188
|
+
except (json.JSONDecodeError, TypeError):
|
|
189
|
+
filtered[dict_field] = None
|
|
190
|
+
return cls(**filtered)
|
|
191
|
+
|
|
192
|
+
def get_mcp_metadata_obj(self) -> Optional[MCPMetadata]:
|
|
193
|
+
if self.mcp_metadata:
|
|
194
|
+
return MCPMetadata.from_dict(self.mcp_metadata)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class Publisher:
|
|
200
|
+
id: str = ""
|
|
201
|
+
display_name: str = ""
|
|
202
|
+
slug: str = ""
|
|
203
|
+
primary_proof_type: str = "account"
|
|
204
|
+
primary_proof_value: str = ""
|
|
205
|
+
verified: bool = False
|
|
206
|
+
verified_at: Optional[str] = None
|
|
207
|
+
verification_method: Optional[str] = None
|
|
208
|
+
contact_url: Optional[str] = None
|
|
209
|
+
created_at: str = ""
|
|
210
|
+
updated_at: str = ""
|
|
211
|
+
|
|
212
|
+
def __post_init__(self):
|
|
213
|
+
if not self.id:
|
|
214
|
+
self.id = str(uuid.uuid4())
|
|
215
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
216
|
+
if not self.created_at:
|
|
217
|
+
self.created_at = now
|
|
218
|
+
if not self.updated_at:
|
|
219
|
+
self.updated_at = now
|
|
220
|
+
|
|
221
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
222
|
+
return asdict(self)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Publisher":
|
|
226
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
227
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Category:
|
|
232
|
+
slug: str = ""
|
|
233
|
+
display_name: str = ""
|
|
234
|
+
description: str = ""
|
|
235
|
+
sort_order: int = 0
|
|
236
|
+
|
|
237
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
238
|
+
return asdict(self)
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Category":
|
|
242
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
243
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class Subcategory:
|
|
248
|
+
slug: str = ""
|
|
249
|
+
category_slug: str = ""
|
|
250
|
+
display_name: str = ""
|
|
251
|
+
sort_order: int = 0
|
|
252
|
+
|
|
253
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
254
|
+
return asdict(self)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Subcategory":
|
|
258
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
259
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class TagCount:
|
|
264
|
+
tag: str = ""
|
|
265
|
+
count: int = 0
|
|
266
|
+
|
|
267
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
268
|
+
return asdict(self)
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TagCount":
|
|
272
|
+
return cls(tag=data.get("tag", ""), count=data.get("count", 0))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class Collection:
|
|
277
|
+
id: str = ""
|
|
278
|
+
slug: str = ""
|
|
279
|
+
display_name: str = ""
|
|
280
|
+
description: str = ""
|
|
281
|
+
listing_ids: List[str] = field(default_factory=list)
|
|
282
|
+
curated_by: Optional[str] = None
|
|
283
|
+
created_at: str = ""
|
|
284
|
+
updated_at: str = ""
|
|
285
|
+
|
|
286
|
+
def __post_init__(self):
|
|
287
|
+
if not self.id:
|
|
288
|
+
self.id = str(uuid.uuid4())
|
|
289
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
290
|
+
if not self.created_at:
|
|
291
|
+
self.created_at = now
|
|
292
|
+
if not self.updated_at:
|
|
293
|
+
self.updated_at = now
|
|
294
|
+
|
|
295
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
296
|
+
return asdict(self)
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Collection":
|
|
300
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
301
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
302
|
+
if "listing_ids" in filtered and isinstance(filtered["listing_ids"], str):
|
|
303
|
+
try:
|
|
304
|
+
filtered["listing_ids"] = json.loads(filtered["listing_ids"])
|
|
305
|
+
except (json.JSONDecodeError, TypeError):
|
|
306
|
+
filtered["listing_ids"] = []
|
|
307
|
+
return cls(**filtered)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class ClaimRequest:
|
|
312
|
+
id: str = ""
|
|
313
|
+
listing_id: str = ""
|
|
314
|
+
publisher_id: str = ""
|
|
315
|
+
proof_type: str = "repo-file"
|
|
316
|
+
proof_value: str = ""
|
|
317
|
+
status: str = "pending"
|
|
318
|
+
submitted_at: str = ""
|
|
319
|
+
reviewed_at: Optional[str] = None
|
|
320
|
+
reviewed_by: Optional[str] = None
|
|
321
|
+
|
|
322
|
+
def __post_init__(self):
|
|
323
|
+
if not self.id:
|
|
324
|
+
self.id = str(uuid.uuid4())
|
|
325
|
+
if not self.submitted_at:
|
|
326
|
+
self.submitted_at = datetime.now(timezone.utc).isoformat()
|
|
327
|
+
|
|
328
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
329
|
+
return asdict(self)
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ClaimRequest":
|
|
333
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
334
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass
|
|
338
|
+
class VerificationRecord:
|
|
339
|
+
id: str = ""
|
|
340
|
+
listing_id: str = ""
|
|
341
|
+
method: str = "repo-token-file"
|
|
342
|
+
result: str = "pass"
|
|
343
|
+
details: str = ""
|
|
344
|
+
verified_at: str = ""
|
|
345
|
+
expires_at: Optional[str] = None
|
|
346
|
+
verifier: str = "system"
|
|
347
|
+
|
|
348
|
+
def __post_init__(self):
|
|
349
|
+
if not self.id:
|
|
350
|
+
self.id = str(uuid.uuid4())
|
|
351
|
+
if not self.verified_at:
|
|
352
|
+
self.verified_at = datetime.now(timezone.utc).isoformat()
|
|
353
|
+
|
|
354
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
355
|
+
return asdict(self)
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def from_dict(cls, data: Dict[str, Any]) -> "VerificationRecord":
|
|
359
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
360
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class SearchQuery:
|
|
365
|
+
text: Optional[str] = None
|
|
366
|
+
package_type: Optional[str] = None
|
|
367
|
+
category: Optional[str] = None
|
|
368
|
+
subcategory: Optional[str] = None
|
|
369
|
+
tags: Optional[List[str]] = None
|
|
370
|
+
trust_state: Optional[str] = None
|
|
371
|
+
min_trust_state: Optional[str] = None
|
|
372
|
+
publisher_id: Optional[str] = None
|
|
373
|
+
installability: Optional[str] = None
|
|
374
|
+
license: Optional[str] = None
|
|
375
|
+
maturity: Optional[str] = None
|
|
376
|
+
mcp_client: Optional[str] = None
|
|
377
|
+
mcp_transport: Optional[str] = None
|
|
378
|
+
mcp_hosted: Optional[bool] = None
|
|
379
|
+
limit: int = 50
|
|
380
|
+
offset: int = 0
|
|
381
|
+
sort_by: str = "relevance"
|
|
382
|
+
|
|
383
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
384
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SearchQuery":
|
|
388
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
389
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Faceted search engine for Exchange listings.
|
|
2
|
+
|
|
3
|
+
Builds dynamic SQL WHERE clauses from SearchQuery facets.
|
|
4
|
+
Supports text search, type/category/trust/tag/publisher/mcp-client filtering,
|
|
5
|
+
sorting, and pagination.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .models import Listing, SearchQuery
|
|
13
|
+
from .trust import TrustState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExchangeSearch:
|
|
17
|
+
|
|
18
|
+
def __init__(self, db_path: Optional[Path] = None):
|
|
19
|
+
if db_path is None:
|
|
20
|
+
db_path = Path.home() / ".capacium" / "registry.db"
|
|
21
|
+
self.db_path = db_path
|
|
22
|
+
|
|
23
|
+
def _connect(self) -> sqlite3.Connection:
|
|
24
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
25
|
+
conn.row_factory = sqlite3.Row
|
|
26
|
+
return conn
|
|
27
|
+
|
|
28
|
+
def search(self, query: SearchQuery) -> Tuple[List[Listing], int]:
|
|
29
|
+
conditions = []
|
|
30
|
+
params = []
|
|
31
|
+
|
|
32
|
+
if query.text:
|
|
33
|
+
conditions.append(
|
|
34
|
+
"(canonical_name LIKE ? OR short_description LIKE ? OR long_description LIKE ?)"
|
|
35
|
+
)
|
|
36
|
+
like = f"%{query.text}%"
|
|
37
|
+
params.extend([like, like, like])
|
|
38
|
+
|
|
39
|
+
if query.package_type:
|
|
40
|
+
conditions.append("package_type = ?")
|
|
41
|
+
params.append(query.package_type)
|
|
42
|
+
|
|
43
|
+
if query.category:
|
|
44
|
+
conditions.append("primary_category = ?")
|
|
45
|
+
params.append(query.category)
|
|
46
|
+
|
|
47
|
+
if query.subcategory:
|
|
48
|
+
conditions.append("subcategory = ?")
|
|
49
|
+
params.append(query.subcategory)
|
|
50
|
+
|
|
51
|
+
if query.trust_state:
|
|
52
|
+
conditions.append("trust_state = ?")
|
|
53
|
+
params.append(query.trust_state)
|
|
54
|
+
|
|
55
|
+
if query.min_trust_state:
|
|
56
|
+
try:
|
|
57
|
+
from .trust import normalize_legacy_state, LEGACY_STATE_MAP
|
|
58
|
+
normalized = normalize_legacy_state(query.min_trust_state)
|
|
59
|
+
min_state = TrustState(normalized)
|
|
60
|
+
valid_states = [
|
|
61
|
+
s.value for s in TrustState.ordering()
|
|
62
|
+
if s >= min_state
|
|
63
|
+
]
|
|
64
|
+
# Include legacy aliases that map to valid states
|
|
65
|
+
for legacy, modern in LEGACY_STATE_MAP.items():
|
|
66
|
+
if modern in valid_states and legacy not in valid_states:
|
|
67
|
+
valid_states.append(legacy)
|
|
68
|
+
if valid_states:
|
|
69
|
+
placeholders = ", ".join("?" for _ in valid_states)
|
|
70
|
+
conditions.append(f"trust_state IN ({placeholders})")
|
|
71
|
+
params.extend(valid_states)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
if query.publisher_id:
|
|
76
|
+
conditions.append("publisher_id = ?")
|
|
77
|
+
params.append(query.publisher_id)
|
|
78
|
+
|
|
79
|
+
if query.installability:
|
|
80
|
+
conditions.append("installability = ?")
|
|
81
|
+
params.append(query.installability)
|
|
82
|
+
|
|
83
|
+
if query.license:
|
|
84
|
+
conditions.append("license = ?")
|
|
85
|
+
params.append(query.license)
|
|
86
|
+
|
|
87
|
+
if query.maturity:
|
|
88
|
+
conditions.append("maturity = ?")
|
|
89
|
+
params.append(query.maturity)
|
|
90
|
+
|
|
91
|
+
if query.tags:
|
|
92
|
+
for tag in query.tags:
|
|
93
|
+
conditions.append("tags LIKE ?")
|
|
94
|
+
params.append(f'%"{tag}"%')
|
|
95
|
+
|
|
96
|
+
if query.mcp_client:
|
|
97
|
+
conditions.append("mcp_metadata LIKE ?")
|
|
98
|
+
params.append(f'%"{query.mcp_client}"%')
|
|
99
|
+
|
|
100
|
+
if query.mcp_transport:
|
|
101
|
+
conditions.append("mcp_metadata LIKE ?")
|
|
102
|
+
params.append(f'%"transport": "{query.mcp_transport}"%')
|
|
103
|
+
|
|
104
|
+
if query.mcp_hosted is not None:
|
|
105
|
+
hosted_str = "true" if query.mcp_hosted else "false"
|
|
106
|
+
conditions.append("mcp_metadata LIKE ?")
|
|
107
|
+
params.append(f'%"hosted": {hosted_str}%')
|
|
108
|
+
|
|
109
|
+
where = ""
|
|
110
|
+
if conditions:
|
|
111
|
+
where = " WHERE " + " AND ".join(conditions)
|
|
112
|
+
|
|
113
|
+
conn = self._connect()
|
|
114
|
+
try:
|
|
115
|
+
count_row = conn.execute(
|
|
116
|
+
f"SELECT count(*) FROM listings{where}", params
|
|
117
|
+
).fetchone()
|
|
118
|
+
total_count = count_row[0] if count_row else 0
|
|
119
|
+
|
|
120
|
+
sort_map = {
|
|
121
|
+
"name": "canonical_name ASC",
|
|
122
|
+
"trust": "trust_state ASC",
|
|
123
|
+
"updated": "updated_at DESC",
|
|
124
|
+
"relevance": "updated_at DESC",
|
|
125
|
+
}
|
|
126
|
+
order_by = sort_map.get(query.sort_by, "updated_at DESC")
|
|
127
|
+
|
|
128
|
+
full_query = f"SELECT * FROM listings{where} ORDER BY {order_by} LIMIT ? OFFSET ?"
|
|
129
|
+
params.extend([query.limit, query.offset])
|
|
130
|
+
|
|
131
|
+
rows = conn.execute(full_query, params).fetchall()
|
|
132
|
+
listings = [Listing.from_dict(dict(row)) for row in rows]
|
|
133
|
+
|
|
134
|
+
return listings, total_count
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
def search_simple(self, text: str, limit: int = 50) -> List[Listing]:
|
|
139
|
+
q = SearchQuery(text=text, limit=limit)
|
|
140
|
+
listings, _ = self.search(q)
|
|
141
|
+
return listings
|
capacium_models/trust.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""TrustState machine for Exchange listings.
|
|
2
|
+
|
|
3
|
+
Enforces valid state transitions, records history, and validates entry criteria.
|
|
4
|
+
|
|
5
|
+
v2.1: Unified 5-state model (discovered → pending_review → verified → signed → deprecated).
|
|
6
|
+
Legacy states (indexed, claimed, audited) are mapped via normalize_legacy_state().
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
from .models import Listing, TrustTransition
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TrustState(str, Enum):
|
|
17
|
+
DISCOVERED = "discovered" # Found by crawler, minimal validation
|
|
18
|
+
PENDING_REVIEW = "pending_review" # Schema validated, composite score computed (formerly "audited")
|
|
19
|
+
VERIFIED = "verified" # Publisher claimed and ownership verified
|
|
20
|
+
SIGNED = "signed" # Cryptographic signature present and valid
|
|
21
|
+
DEPRECATED = "deprecated" # End-of-life, superseded, or abandoned
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def ordering(cls) -> List["TrustState"]:
|
|
25
|
+
return [
|
|
26
|
+
cls.DISCOVERED,
|
|
27
|
+
cls.PENDING_REVIEW,
|
|
28
|
+
cls.VERIFIED,
|
|
29
|
+
cls.SIGNED,
|
|
30
|
+
cls.DEPRECATED,
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def __lt__(self, other):
|
|
34
|
+
if self.__class__ is other.__class__:
|
|
35
|
+
ordering = self.ordering()
|
|
36
|
+
return ordering.index(self) < ordering.index(other)
|
|
37
|
+
return NotImplemented
|
|
38
|
+
|
|
39
|
+
def __gt__(self, other):
|
|
40
|
+
if self.__class__ is other.__class__:
|
|
41
|
+
ordering = self.ordering()
|
|
42
|
+
return ordering.index(self) > ordering.index(other)
|
|
43
|
+
return NotImplemented
|
|
44
|
+
|
|
45
|
+
def __ge__(self, other):
|
|
46
|
+
if self.__class__ is other.__class__:
|
|
47
|
+
ordering = self.ordering()
|
|
48
|
+
return ordering.index(self) >= ordering.index(other)
|
|
49
|
+
return NotImplemented
|
|
50
|
+
|
|
51
|
+
def __le__(self, other):
|
|
52
|
+
if self.__class__ is other.__class__:
|
|
53
|
+
ordering = self.ordering()
|
|
54
|
+
return ordering.index(self) <= ordering.index(other)
|
|
55
|
+
return NotImplemented
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Legacy state mapping for backward compatibility
|
|
59
|
+
LEGACY_STATE_MAP: Dict[str, str] = {
|
|
60
|
+
"indexed": "pending_review",
|
|
61
|
+
"claimed": "verified",
|
|
62
|
+
"audited": "pending_review", # v1.0 → v2.1 migration
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_legacy_state(state: str) -> str:
|
|
67
|
+
"""Map old trust states to current model.
|
|
68
|
+
|
|
69
|
+
indexed/audited → pending_review, claimed → verified.
|
|
70
|
+
All other states pass through unchanged.
|
|
71
|
+
"""
|
|
72
|
+
return LEGACY_STATE_MAP.get(state, state)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TrustTransitionError(Exception):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
VALID_TRANSITIONS = {
|
|
80
|
+
TrustState.DISCOVERED: {TrustState.PENDING_REVIEW},
|
|
81
|
+
TrustState.PENDING_REVIEW: {TrustState.VERIFIED, TrustState.DISCOVERED},
|
|
82
|
+
TrustState.VERIFIED: {TrustState.SIGNED, TrustState.PENDING_REVIEW},
|
|
83
|
+
TrustState.SIGNED: {TrustState.VERIFIED, TrustState.DEPRECATED},
|
|
84
|
+
TrustState.DEPRECATED: {TrustState.PENDING_REVIEW},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TrustMachine:
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def normalize_legacy_state(state: str) -> str:
|
|
92
|
+
"""Map old trust states to new ones (convenience wrapper)."""
|
|
93
|
+
return normalize_legacy_state(state)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def can_transition(from_state: TrustState, to_state: TrustState) -> bool:
|
|
97
|
+
allowed = VALID_TRANSITIONS.get(from_state, set())
|
|
98
|
+
return to_state in allowed
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def validate_entry_criteria(listing: Listing, target_state: TrustState) -> list:
|
|
102
|
+
errors = []
|
|
103
|
+
|
|
104
|
+
if target_state == TrustState.PENDING_REVIEW:
|
|
105
|
+
if not listing.canonical_name:
|
|
106
|
+
errors.append("Pending review state requires canonical_name")
|
|
107
|
+
if not listing.short_description:
|
|
108
|
+
errors.append("Pending review state requires short_description")
|
|
109
|
+
if not listing.canonical_source_url:
|
|
110
|
+
errors.append("Pending review state requires canonical_source_url")
|
|
111
|
+
|
|
112
|
+
elif target_state == TrustState.VERIFIED:
|
|
113
|
+
if not listing.publisher_id:
|
|
114
|
+
errors.append("Verified state requires publisher_id")
|
|
115
|
+
|
|
116
|
+
elif target_state == TrustState.SIGNED:
|
|
117
|
+
if not listing.fingerprint:
|
|
118
|
+
errors.append("Signed state requires fingerprint")
|
|
119
|
+
if not listing.signature_value:
|
|
120
|
+
errors.append("Signed state requires signature_value")
|
|
121
|
+
|
|
122
|
+
return errors
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def transition(
|
|
126
|
+
cls,
|
|
127
|
+
listing: Listing,
|
|
128
|
+
to_state: TrustState,
|
|
129
|
+
reason: str = "",
|
|
130
|
+
triggered_by: str = "system",
|
|
131
|
+
) -> Listing:
|
|
132
|
+
# Normalize legacy states before processing
|
|
133
|
+
current_state_str = normalize_legacy_state(listing.trust_state)
|
|
134
|
+
from_state = TrustState(current_state_str)
|
|
135
|
+
|
|
136
|
+
if from_state == to_state:
|
|
137
|
+
raise TrustTransitionError(
|
|
138
|
+
f"Listing is already in state '{to_state.value}'"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not cls.can_transition(from_state, to_state):
|
|
142
|
+
raise TrustTransitionError(
|
|
143
|
+
f"Cannot transition from '{from_state.value}' to '{to_state.value}'. "
|
|
144
|
+
f"Valid targets: {[s.value for s in VALID_TRANSITIONS.get(from_state, set())]}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
criteria_errors = cls.validate_entry_criteria(listing, to_state)
|
|
148
|
+
if criteria_errors:
|
|
149
|
+
raise TrustTransitionError(
|
|
150
|
+
f"Entry criteria not met for '{to_state.value}': {'; '.join(criteria_errors)}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
transition = TrustTransition(
|
|
154
|
+
from_state=from_state.value,
|
|
155
|
+
to_state=to_state.value,
|
|
156
|
+
reason=reason,
|
|
157
|
+
triggered_by=triggered_by,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
listing.trust_state = to_state.value
|
|
161
|
+
listing.trust_history.append(transition.to_dict())
|
|
162
|
+
listing.updated_at = datetime.now(timezone.utc).isoformat()
|
|
163
|
+
|
|
164
|
+
return listing
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── V2 Trust Badge Dictionary (single source of truth for all surfaces) ──
|
|
168
|
+
|
|
169
|
+
TRUST_BADGES: Dict[str, Dict[str, str]] = {
|
|
170
|
+
"discovered": {
|
|
171
|
+
"label": "Discovered",
|
|
172
|
+
"symbol": "○",
|
|
173
|
+
"color": "grey",
|
|
174
|
+
"description": "Found by crawler, minimal validation",
|
|
175
|
+
"order": 0,
|
|
176
|
+
},
|
|
177
|
+
"pending_review": {
|
|
178
|
+
"label": "Pending Review",
|
|
179
|
+
"symbol": "◉",
|
|
180
|
+
"color": "yellow",
|
|
181
|
+
"description": "Schema validated, composite score computed",
|
|
182
|
+
"order": 1,
|
|
183
|
+
},
|
|
184
|
+
"verified": {
|
|
185
|
+
"label": "Verified",
|
|
186
|
+
"symbol": "✓",
|
|
187
|
+
"color": "green",
|
|
188
|
+
"description": "Publisher claimed and ownership verified",
|
|
189
|
+
"order": 2,
|
|
190
|
+
},
|
|
191
|
+
"signed": {
|
|
192
|
+
"label": "Signed",
|
|
193
|
+
"symbol": "✦",
|
|
194
|
+
"color": "blue",
|
|
195
|
+
"description": "Cryptographic signature present and valid",
|
|
196
|
+
"order": 3,
|
|
197
|
+
},
|
|
198
|
+
"deprecated": {
|
|
199
|
+
"label": "Deprecated",
|
|
200
|
+
"symbol": "✗",
|
|
201
|
+
"color": "red",
|
|
202
|
+
"description": "End-of-life, superseded, or abandoned",
|
|
203
|
+
"order": 4,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_trust_badge(trust_state: str) -> Dict[str, str]:
|
|
209
|
+
"""Return the badge definition for a trust state. Falls back to 'discovered' for unknown states."""
|
|
210
|
+
normalized = normalize_legacy_state(trust_state)
|
|
211
|
+
return TRUST_BADGES.get(normalized, TRUST_BADGES["discovered"])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capacium-models
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Shared domain models for the Capacium ecosystem — Listing, TrustState, TrustMachine, and more
|
|
5
|
+
Author: Capacium
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Capacium/capacium-models
|
|
8
|
+
Project-URL: Repository, https://github.com/Capacium/capacium-models
|
|
9
|
+
Project-URL: Issues, https://github.com/Capacium/capacium-models/issues
|
|
10
|
+
Keywords: capacium,models,listing,trust,ai-agents
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# capacium-models
|
|
28
|
+
|
|
29
|
+
> [!NOTE]
|
|
30
|
+
> **Public mirror.** The canonical repository is hosted on our self-hosted git.
|
|
31
|
+
> This GitHub copy is a read-only mirror kept in sync for visibility and
|
|
32
|
+
> installation. Bug reports are welcome via Issues; pull requests are applied
|
|
33
|
+
> upstream and synced back here.
|
|
34
|
+
|
|
35
|
+
Shared domain models for the Capacium ecosystem. Used by `capacium-exchange` and `capacium-crawler`.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install capacium-models
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Models
|
|
44
|
+
|
|
45
|
+
| Model | Description |
|
|
46
|
+
|-------|-------------|
|
|
47
|
+
| `TrustState` | Enum: DISCOVERED → PENDING_REVIEW → VERIFIED → SIGNED → DEPRECATED |
|
|
48
|
+
| `TrustMachine` | State machine with VALID_TRANSITIONS, can_transition(), transition() |
|
|
49
|
+
| `Listing` | Full listing model (24 fields, auto-UUID, auto-timestamps) |
|
|
50
|
+
| `Publisher` | Publisher profile with verification support |
|
|
51
|
+
| `MCPClient` | Enum of supported MCP clients |
|
|
52
|
+
| `MCPMetadata` | MCP-specific metadata (transport, runtime, auth_model, etc.) |
|
|
53
|
+
| `Category` / `Subcategory` / `TagCount` | Taxonomy models |
|
|
54
|
+
| `Collection` | Curated collection of listing IDs |
|
|
55
|
+
| `ClaimRequest` / `VerificationRecord` | Trust workflow models |
|
|
56
|
+
| `SearchQuery` | Faceted search query model (16 fields) |
|
|
57
|
+
| `ExchangeSearch` | Faceted search SQL engine |
|
|
58
|
+
|
|
59
|
+
The package-level `TRUST_BADGES` and `get_trust_badge()` exports retain the
|
|
60
|
+
rich badge metadata introduced in v0.4.0. UI-facing canonical text labels,
|
|
61
|
+
including the string-only trust labels, live in `capacium_models.labels`.
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from capacium_models import Listing, TrustState, TrustMachine
|
|
67
|
+
|
|
68
|
+
# Create a discovered listing
|
|
69
|
+
listing = Listing(
|
|
70
|
+
canonical_name="org/my-mcp-server",
|
|
71
|
+
package_type="mcp-server",
|
|
72
|
+
canonical_source_url="https://github.com/org/my-mcp-server",
|
|
73
|
+
short_description="A cool MCP server",
|
|
74
|
+
trust_state=TrustState.DISCOVERED,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Transition through trust states
|
|
78
|
+
machine = TrustMachine()
|
|
79
|
+
if machine.can_transition(listing.trust_state, TrustState.PENDING_REVIEW):
|
|
80
|
+
listing = machine.transition(
|
|
81
|
+
listing,
|
|
82
|
+
TrustState.PENDING_REVIEW,
|
|
83
|
+
"Validated metadata",
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Dependencies
|
|
88
|
+
|
|
89
|
+
Zero runtime dependencies — stdlib only (`dataclasses`, `enum`, `uuid`, `datetime`, `json`, `typing`).
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
Canonical repository: **self-hosted Forgejo** — `git.langevc.com/capacium/capacium-models`
|
|
98
|
+
(`git clone git@git.langevc.com:capacium/capacium-models.git`). Develop against the Forgejo
|
|
99
|
+
clone and open pull requests there. The GitHub copy is a read-only mirror.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
capacium_models/__init__.py,sha256=WPuprI0Bx8EcYYFMLgvRxgygdXX3JLfV1-7PZpjWjF0,1375
|
|
2
|
+
capacium_models/keys.py,sha256=u98gw9FFTSBBdv9JM0_o4xWUyFdUa8GuiZIDEbJs750,5299
|
|
3
|
+
capacium_models/labels.py,sha256=sopRzLjyB7PyWnRLKO8caduu_RwPH1masmCpyMIDaCg,1956
|
|
4
|
+
capacium_models/models.py,sha256=19Xtw7cAs4RIXLyhcq1BjKbqQwVdguLFxyZjtnmLbno,12350
|
|
5
|
+
capacium_models/search.py,sha256=OPNM7RlZ8sx8ipT_XE6C9VA-JwGNWKLJM3yvxEz0c7w,4863
|
|
6
|
+
capacium_models/trust.py,sha256=z8yom8kpcrV6KGRo6FWEai_56TibSOKEVtj3HIx4z7Q,7111
|
|
7
|
+
capacium_models-0.5.0.dist-info/licenses/LICENSE,sha256=XU1ruW3qAVD_Kyt2yvyVNadjak_NPX6YE8oRdnkxn7g,1065
|
|
8
|
+
capacium_models-0.5.0.dist-info/METADATA,sha256=Z7jDfVtXcMPxu1ZzrEkRjSPvJObc_aDe-J-p8bpfW6M,3608
|
|
9
|
+
capacium_models-0.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
capacium_models-0.5.0.dist-info/top_level.txt,sha256=HlNIpG2DeFPM7x6qXh8d_0s6Bvjh8cmKmZn7XT1tDg0,16
|
|
11
|
+
capacium_models-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Capacium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capacium_models
|