conceptkernel 1.0.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.
cklib/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """cklib — CKP v3.5 Python runtime for Concept Kernels.
2
+
3
+ Usage:
4
+ from cklib import KernelProcessor, on, emit
5
+ from cklib.urn import parse_urn, validate_urn, build_urn
6
+ from cklib.instance import create_instance, seal_instance
7
+ from cklib.dispatch import send_action
8
+ from cklib.serve import KernelServer
9
+ from cklib.auth import CKAuth
10
+ from cklib.entities import EntityManager
11
+ from cklib.actions import resolve_action_type, check_access
12
+ from cklib.prov import ProvChain, Session, ActionRecord, verified_action, list_sessions, get_session
13
+ from cklib.ledger import log_action, read_ledger
14
+ """
15
+ from cklib.processor import KernelProcessor
16
+ from cklib.events import on, emit
17
+ from cklib.entities import EntityManager
18
+ from cklib.actions import resolve_action_type, check_access
19
+ from cklib.prov import ProvChain, Session, ActionRecord, verified_action, list_sessions, get_session
20
+ from cklib.ledger import log_action, read_ledger
21
+
22
+ __version__ = "1.0.0"
23
+ __all__ = [
24
+ "KernelProcessor", "on", "emit", "EntityManager",
25
+ "resolve_action_type", "check_access",
26
+ "ProvChain", "Session", "ActionRecord", "verified_action",
27
+ "list_sessions", "get_session",
28
+ "log_action", "read_ledger",
29
+ ]
cklib/actions.py ADDED
@@ -0,0 +1,277 @@
1
+ """Action type resolution, composition via edges, and RBAC checks.
2
+
3
+ Implements SPEC.ACTION-TYPES.md §4 (type resolution), §3 (composition),
4
+ and v3.4 access level enforcement.
5
+
6
+ Usage:
7
+ from cklib.actions import resolve_action_type, resolve_composed_actions, check_access
8
+
9
+ atype = resolve_action_type("email.send") # "operate"
10
+ composed = resolve_composed_actions("concepts/CS.Voting")
11
+ # {'email.send': 'CK.Email', 'email.invite': 'CK.Email', 'register': 'CK.Signup'}
12
+ ok = check_access("email.send", "auth", {"sub": "alice"}, grants)
13
+ """
14
+ import os
15
+
16
+ import yaml
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # §4 — Action Type Map (from SPEC.ACTION-TYPES.md)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ ACTION_TYPE_MAP = {
24
+ # inspect
25
+ "status": "inspect", "ontology": "inspect", "show": "inspect",
26
+ "list": "inspect", "version": "inspect",
27
+
28
+ # check
29
+ "check.*": "check", "validate": "check", "probe.*": "check",
30
+
31
+ # mutate
32
+ "create": "mutate", "update": "mutate", "complete": "mutate",
33
+ "assign": "mutate", "enable": "mutate", "disable": "mutate",
34
+ "save": "mutate", "load": "mutate",
35
+
36
+ # operate
37
+ "spawn": "operate", "execute": "operate", "render": "operate",
38
+ "run": "operate", "fire": "operate", "play": "operate", "stop": "operate",
39
+ "send": "operate", "invite": "operate",
40
+
41
+ # query
42
+ "catalog": "query", "graph": "query", "search": "query",
43
+ "pending": "query", "tasks": "query",
44
+
45
+ # deploy
46
+ "deploy.*": "deploy", "apply": "deploy", "route.*": "deploy",
47
+
48
+ # transfer
49
+ "export.*": "transfer", "import.*": "transfer",
50
+ "sync": "transfer", "regenerate": "transfer",
51
+ }
52
+
53
+ VALID_ACTION_TYPES = {"inspect", "check", "mutate", "operate", "query", "deploy", "transfer"}
54
+
55
+
56
+ def resolve_action_type(action_name):
57
+ """Resolve dotted action name to its type per SPEC.ACTION-TYPES.md §4.
58
+
59
+ Resolution order:
60
+ 1. Exact match (e.g. "status" → inspect)
61
+ 2. Suffix match (e.g. "task.create" → "create" → mutate)
62
+ 3. Prefix wildcard (e.g. "check.identity" → "check.*" → check)
63
+ 4. Default: inspect (read-only)
64
+ """
65
+ # Exact match
66
+ if action_name in ACTION_TYPE_MAP:
67
+ return ACTION_TYPE_MAP[action_name]
68
+
69
+ # Suffix match: task.create → create → mutate
70
+ suffix = action_name.rsplit(".", 1)[-1]
71
+ if suffix in ACTION_TYPE_MAP:
72
+ return ACTION_TYPE_MAP[suffix]
73
+
74
+ # Prefix wildcard: check.identity → check.* → check
75
+ prefix = action_name.split(".")[0]
76
+ pattern = prefix + ".*"
77
+ if pattern in ACTION_TYPE_MAP:
78
+ return ACTION_TYPE_MAP[pattern]
79
+
80
+ return "inspect" # default: read-only
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # §3 — Composed Actions via Edges
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _load_ck_yaml(ck_dir):
88
+ """Load conceptkernel.yaml, return (data, error)."""
89
+ yaml_path = os.path.join(ck_dir, "conceptkernel.yaml")
90
+ if not os.path.isfile(yaml_path):
91
+ return None, "conceptkernel.yaml not found"
92
+ try:
93
+ with open(yaml_path) as f:
94
+ return yaml.safe_load(f), None
95
+ except Exception as e:
96
+ return None, str(e)
97
+
98
+
99
+ def _get_actions_from_spec(data):
100
+ """Extract action names from spec.actions (common + unique)."""
101
+ actions = {}
102
+ spec_actions = data.get("spec", {}).get("actions", {})
103
+ for section in ("common", "unique"):
104
+ for act in spec_actions.get(section, []):
105
+ if isinstance(act, dict) and "name" in act:
106
+ actions[act["name"]] = act
107
+ return actions
108
+
109
+
110
+ def _find_ck_dir_by_name(target_name, concepts_dir):
111
+ """Find a CK directory by its metadata.name."""
112
+ if not os.path.isdir(concepts_dir):
113
+ return None
114
+ for entry in os.listdir(concepts_dir):
115
+ candidate = os.path.join(concepts_dir, entry)
116
+ if not os.path.isdir(candidate):
117
+ continue
118
+ yaml_path = os.path.join(candidate, "conceptkernel.yaml")
119
+ if not os.path.isfile(yaml_path):
120
+ continue
121
+ try:
122
+ with open(yaml_path) as f:
123
+ d = yaml.safe_load(f)
124
+ if isinstance(d, dict) and d.get("metadata", {}).get("name") == target_name:
125
+ return candidate
126
+ except Exception:
127
+ continue
128
+ return None
129
+
130
+
131
+ def resolve_composed_actions(ck_dir):
132
+ """Walk COMPOSES/EXTENDS edges, collect target kernel actions.
133
+
134
+ Returns dict: {action_name: target_kernel_name}
135
+ Only follows COMPOSES and EXTENDS edges (not TRIGGERS/PRODUCES/LOOPS_WITH).
136
+ """
137
+ ck_dir = os.path.abspath(ck_dir)
138
+ data, err = _load_ck_yaml(ck_dir)
139
+ if err:
140
+ return {}
141
+
142
+ # Derive concepts_dir
143
+ concepts_dir = os.path.dirname(ck_dir)
144
+
145
+ edges_section = data.get("spec", {}).get("edges", data.get("edges", {}))
146
+ outbound = edges_section.get("outbound", [])
147
+
148
+ composed = {}
149
+ for edge in outbound:
150
+ if not isinstance(edge, dict):
151
+ continue
152
+ predicate = edge.get("predicate", "")
153
+ if predicate not in ("COMPOSES", "EXTENDS"):
154
+ continue
155
+
156
+ target_name = edge.get("target_kernel", "")
157
+ if not target_name:
158
+ continue
159
+
160
+ target_dir = _find_ck_dir_by_name(target_name, concepts_dir)
161
+ if not target_dir:
162
+ continue
163
+
164
+ target_data, terr = _load_ck_yaml(target_dir)
165
+ if terr:
166
+ continue
167
+
168
+ target_actions = _get_actions_from_spec(target_data)
169
+ for action_name in target_actions:
170
+ # Don't override — first edge wins
171
+ if action_name not in composed:
172
+ composed[action_name] = target_name
173
+
174
+ return composed
175
+
176
+
177
+ def get_effective_actions(ck_dir):
178
+ """Return the full effective action set: own + composed.
179
+
180
+ Returns dict: {action_name: {"source": "own"|kernel_name, "spec": action_dict}}
181
+ """
182
+ ck_dir = os.path.abspath(ck_dir)
183
+ data, err = _load_ck_yaml(ck_dir)
184
+ if err:
185
+ return {}
186
+
187
+ effective = {}
188
+
189
+ # Own actions
190
+ own_actions = _get_actions_from_spec(data)
191
+ for name, spec in own_actions.items():
192
+ effective[name] = {"source": "own", "spec": spec}
193
+
194
+ # Composed actions
195
+ composed = resolve_composed_actions(ck_dir)
196
+ for name, kernel_name in composed.items():
197
+ if name not in effective:
198
+ effective[name] = {"source": kernel_name, "spec": {}}
199
+
200
+ return effective
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # RBAC — Access Level Enforcement
205
+ # ---------------------------------------------------------------------------
206
+
207
+ VALID_ACCESS_LEVELS = {"anon", "auth", "owner"}
208
+
209
+
210
+ def check_access(action_name, access_level, caller_claims, grants=None):
211
+ """Verify caller has permission for action based on access level + grants.
212
+
213
+ Args:
214
+ action_name: the action being invoked
215
+ access_level: "anon" | "auth" | "owner" from spec.actions
216
+ caller_claims: JWT claims dict (empty for anonymous)
217
+ grants: optional list of grant dicts from conceptkernel.yaml
218
+
219
+ Returns:
220
+ True if access is allowed, False otherwise.
221
+ """
222
+ if access_level not in VALID_ACCESS_LEVELS:
223
+ return False
224
+
225
+ # anon — always allowed
226
+ if access_level == "anon":
227
+ return True
228
+
229
+ # auth — caller must have a subject
230
+ if not caller_claims or not caller_claims.get("sub"):
231
+ return False
232
+
233
+ if access_level == "auth":
234
+ # If grants exist, check if caller's identity matches any grant
235
+ if grants:
236
+ return _check_grants(action_name, caller_claims, grants)
237
+ # No grants = any authenticated user
238
+ return True
239
+
240
+ # owner — caller must match kernel owner or have admin role
241
+ if access_level == "owner":
242
+ roles = caller_claims.get("realm_access", {}).get("roles", [])
243
+ if "admin" in roles:
244
+ return True
245
+ # Check grants for owner-level access
246
+ if grants:
247
+ return _check_grants(action_name, caller_claims, grants)
248
+ return False
249
+
250
+ return False
251
+
252
+
253
+ def _check_grants(action_name, caller_claims, grants):
254
+ """Check if caller matches any grant entry for the given action."""
255
+ if not isinstance(grants, list):
256
+ grants = [grants]
257
+
258
+ caller_sub = caller_claims.get("sub", "")
259
+ caller_user = caller_claims.get("preferred_username", "")
260
+
261
+ for grant in grants:
262
+ if not isinstance(grant, dict):
263
+ continue
264
+
265
+ identity = grant.get("identity", "")
266
+ actions = grant.get("actions", [])
267
+
268
+ # Check identity match
269
+ if identity == "*" or identity == caller_sub or identity == caller_user:
270
+ # Check action match
271
+ if isinstance(actions, list):
272
+ if "*" in actions or action_name in actions:
273
+ return True
274
+ elif actions == "*" or actions == action_name:
275
+ return True
276
+
277
+ return False
cklib/auth.py ADDED
@@ -0,0 +1,98 @@
1
+ """Shared auth — JWKS + X-Token for CK HTTP endpoints.
2
+
3
+ Loads Keycloak JWKS once at startup. Supports dual auth:
4
+ - X-Token header or ?token= query param (for API clients like Claude voice)
5
+ - Authorization: Bearer <jwt> (for browser/Keycloak-authenticated users)
6
+
7
+ Usage:
8
+ from cklib.auth import CKAuth
9
+
10
+ auth = CKAuth(api_token="my-secret")
11
+ await auth.load_jwks()
12
+ claims = auth.authenticate(request)
13
+ """
14
+ import os
15
+
16
+ import httpx
17
+ import jwt as pyjwt
18
+
19
+ KEYCLOAK_BASE = "https://id.tech.games"
20
+ KEYCLOAK_REALM = "techgames"
21
+ JWKS_URL = f"{KEYCLOAK_BASE}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs"
22
+
23
+
24
+ class CKAuth:
25
+ """Dual auth: static API token + Keycloak JWT."""
26
+
27
+ def __init__(self, api_token=None):
28
+ self.api_token = api_token or os.environ.get("CK_API_TOKEN")
29
+ self._jwks_keys = None
30
+
31
+ async def load_jwks(self):
32
+ """Fetch JWKS from Keycloak once at startup."""
33
+ try:
34
+ async with httpx.AsyncClient(verify=True, timeout=15) as client:
35
+ resp = await client.get(JWKS_URL)
36
+ resp.raise_for_status()
37
+ data = resp.json()
38
+ self._jwks_keys = {k["kid"]: k for k in data.get("keys", [])}
39
+ return len(self._jwks_keys)
40
+ except Exception as e:
41
+ print(f"[auth] JWKS load failed: {e}")
42
+ self._jwks_keys = {}
43
+ return 0
44
+
45
+ @property
46
+ def jwks_loaded(self):
47
+ return self._jwks_keys is not None and len(self._jwks_keys) > 0
48
+
49
+ def authenticate(self, request) -> dict:
50
+ """Authenticate request. Returns claims dict or raises HTTPException."""
51
+ from fastapi import HTTPException
52
+
53
+ # Check X-Token first (API client path)
54
+ x_token = (request.headers.get("X-Token")
55
+ or request.query_params.get("token"))
56
+ if x_token:
57
+ if not self.api_token:
58
+ raise HTTPException(401, "API token not configured")
59
+ if x_token != self.api_token:
60
+ raise HTTPException(401, "Invalid API token")
61
+ return {"preferred_username": "api-client", "sub": "api-client"}
62
+
63
+ # Fall back to JWT
64
+ auth_header = request.headers.get("Authorization", "")
65
+ if not auth_header.startswith("Bearer "):
66
+ raise HTTPException(401, "Missing X-Token or Bearer token")
67
+
68
+ token = auth_header[7:]
69
+ return self._verify_jwt(token)
70
+
71
+ def _verify_jwt(self, token: str) -> dict:
72
+ from fastapi import HTTPException
73
+
74
+ if not self._jwks_keys:
75
+ raise HTTPException(401, "JWKS not loaded")
76
+
77
+ try:
78
+ header = pyjwt.get_unverified_header(token)
79
+ except pyjwt.DecodeError:
80
+ raise HTTPException(401, "Invalid token")
81
+
82
+ kid = header.get("kid")
83
+ if kid not in self._jwks_keys:
84
+ raise HTTPException(401, "Unknown key ID")
85
+
86
+ jwk_data = self._jwks_keys[kid]
87
+ alg = header.get("alg", jwk_data.get("alg", "RS256"))
88
+ public_key = pyjwt.algorithms.RSAAlgorithm.from_jwk(jwk_data)
89
+
90
+ try:
91
+ return pyjwt.decode(
92
+ token, public_key, algorithms=[alg],
93
+ options={"verify_aud": False},
94
+ )
95
+ except pyjwt.ExpiredSignatureError:
96
+ raise HTTPException(401, "Token expired")
97
+ except pyjwt.InvalidTokenError as e:
98
+ raise HTTPException(401, f"Invalid token: {e}")
cklib/dispatch.py ADDED
@@ -0,0 +1,179 @@
1
+ """Action dispatch — send actions to kernels locally or via queue.
2
+
3
+ All actions go through the queue. The queue processes FIFO locally.
4
+
5
+ Usage:
6
+ from cklib.dispatch import send_action, queue_action, run_queue
7
+
8
+ # Immediate (bypasses queue for inspect/query actions):
9
+ result = send_action("CK.Task", "status")
10
+
11
+ # Queued (for operate/mutate/check/deploy actions):
12
+ queue_action("CK.Task", "task.create", data={...}, persona="builder")
13
+
14
+ # Process queue:
15
+ run_queue()
16
+ """
17
+ import importlib.util
18
+ import json
19
+ import os
20
+ import time
21
+ import glob
22
+
23
+ PROJECT_ROOT = None
24
+ CONCEPTS_DIR = None
25
+
26
+
27
+ def _init_paths():
28
+ global PROJECT_ROOT, CONCEPTS_DIR
29
+ if PROJECT_ROOT is None:
30
+ PROJECT_ROOT = os.path.abspath(
31
+ os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
32
+ CONCEPTS_DIR = os.path.join(PROJECT_ROOT, "concepts")
33
+
34
+
35
+ def resolve_kernel(name):
36
+ """Resolve kernel name to directory + processor path."""
37
+ _init_paths()
38
+ for prefix in ["", "CK.", "TG.", "LOCAL.", "Test."]:
39
+ ck_dir = os.path.join(CONCEPTS_DIR, prefix + name)
40
+ proc = os.path.join(ck_dir, "tool", "processor.py")
41
+ if os.path.isfile(proc):
42
+ return ck_dir, proc
43
+ return None, None
44
+
45
+
46
+ def load_handler(processor_path):
47
+ """Import handle_message from a processor.py."""
48
+ spec = importlib.util.spec_from_file_location("processor", processor_path)
49
+ mod = importlib.util.module_from_spec(spec)
50
+ old_cwd = os.getcwd()
51
+ os.chdir(os.path.dirname(processor_path))
52
+ try:
53
+ spec.loader.exec_module(mod)
54
+ finally:
55
+ os.chdir(old_cwd)
56
+ return getattr(mod, "handle_message", None)
57
+
58
+
59
+ def send_action(kernel_name, action, data=None):
60
+ """Send action directly — no queue. For inspect/query (stateless reads)."""
61
+ ck_dir, proc_path = resolve_kernel(kernel_name)
62
+ if not proc_path:
63
+ return {"status": "error", "message": "kernel not found: %s" % kernel_name}
64
+
65
+ handler = load_handler(proc_path)
66
+ if not handler:
67
+ return {"status": "error", "message": "no handle_message in %s" % kernel_name}
68
+
69
+ msg = {"action": action}
70
+ if data:
71
+ msg["data"] = data
72
+
73
+ try:
74
+ return handler(msg)
75
+ except Exception as e:
76
+ return {"status": "error", "message": str(e), "kernel": kernel_name}
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Queue — FIFO for local execution
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _queue_dir():
84
+ _init_paths()
85
+ qdir = os.path.join(CONCEPTS_DIR, "LOCAL.ClaudeCode", "storage", "queue")
86
+ os.makedirs(qdir, exist_ok=True)
87
+ return qdir
88
+
89
+
90
+ def queue_action(kernel_name, action, data=None, persona="operator",
91
+ task_id="", goal_id=""):
92
+ """Add an action to the FIFO queue."""
93
+ qdir = _queue_dir()
94
+ ts = int(time.time() * 1000) # millisecond precision for ordering
95
+ seq = len(glob.glob(os.path.join(qdir, "*.json"))) + 1
96
+
97
+ entry = {
98
+ "seq": seq,
99
+ "kernel": kernel_name,
100
+ "action": action,
101
+ "data": data or {},
102
+ "persona": persona,
103
+ "task_id": task_id,
104
+ "goal_id": goal_id,
105
+ "queued_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
106
+ "status": "pending",
107
+ }
108
+
109
+ filename = "%03d-%s-%s-%d.json" % (seq, kernel_name.replace(".", "-"), action.replace(".", "-"), ts)
110
+ with open(os.path.join(qdir, filename), "w") as f:
111
+ json.dump(entry, f, indent=2)
112
+
113
+ return entry
114
+
115
+
116
+ def list_queue():
117
+ """List all pending queue entries in FIFO order."""
118
+ qdir = _queue_dir()
119
+ entries = []
120
+ for fname in sorted(os.listdir(qdir)):
121
+ if fname.endswith(".json"):
122
+ with open(os.path.join(qdir, fname)) as f:
123
+ entry = json.load(f)
124
+ entry["_file"] = fname
125
+ entries.append(entry)
126
+ return [e for e in entries if e.get("status") == "pending"]
127
+
128
+
129
+ def run_queue():
130
+ """Process the queue FIFO — one at a time."""
131
+ _init_paths()
132
+ lock_path = os.path.join(CONCEPTS_DIR, "LOCAL.ClaudeCode", "storage", "lock")
133
+
134
+ # Check lock
135
+ if os.path.exists(lock_path):
136
+ with open(lock_path) as f:
137
+ pid = f.read().strip()
138
+ # Check if process still running
139
+ try:
140
+ os.kill(int(pid), 0)
141
+ return {"status": "locked", "pid": pid, "message": "Queue runner already active"}
142
+ except (OSError, ValueError):
143
+ os.remove(lock_path)
144
+
145
+ # Acquire lock
146
+ with open(lock_path, "w") as f:
147
+ f.write(str(os.getpid()))
148
+
149
+ try:
150
+ pending = list_queue()
151
+ results = []
152
+ total = len(pending)
153
+
154
+ for i, entry in enumerate(pending, 1):
155
+ kernel = entry["kernel"]
156
+ action = entry["action"]
157
+ persona = entry.get("persona", "operator")
158
+
159
+ print("[%d/%d] %s → %s (%s)" % (i, total, entry.get("task_id") or action, kernel, persona))
160
+
161
+ result = send_action(kernel, action, entry.get("data"))
162
+ results.append({"entry": entry, "result": result})
163
+
164
+ # Mark as completed
165
+ entry["status"] = "completed"
166
+ entry["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
167
+ entry["result_status"] = result.get("status", "unknown")
168
+
169
+ qpath = os.path.join(_queue_dir(), entry["_file"])
170
+ with open(qpath, "w") as f:
171
+ json.dump(entry, f, indent=2)
172
+
173
+ status = result.get("status", "?")
174
+ print(" %s" % status)
175
+
176
+ return {"status": "ok", "processed": len(results), "results": results}
177
+ finally:
178
+ if os.path.exists(lock_path):
179
+ os.remove(lock_path)
cklib/entities.py ADDED
@@ -0,0 +1,95 @@
1
+ """EntityManager — shared in-memory entity store with code-based lookup.
2
+
3
+ Extracted from VoiceScreen (_sessions) and CS.Voting (_rooms) patterns.
4
+ Both use: dict store + reverse code index + status tracking + 4-char codes.
5
+
6
+ Usage:
7
+ from cklib.entities import EntityManager
8
+
9
+ rooms = EntityManager(prefix="room")
10
+ room_id, room = rooms.create(owner="alice", name="Quick Vote")
11
+ room_id, room = rooms.get_by_code("AB12")
12
+ rooms.update_status(room_id, "closed")
13
+ """
14
+ import time
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+
18
+
19
+ def generate_code(length=4):
20
+ """Generate a random uppercase hex code (4 chars by default)."""
21
+ return uuid.uuid4().hex[:length].upper()
22
+
23
+
24
+ def generate_entity_id(prefix, code):
25
+ """Generate a timestamped entity ID: {prefix}-{epoch}-{code}."""
26
+ return f"{prefix}-{int(time.time())}-{code.lower()}"
27
+
28
+
29
+ class EntityManager:
30
+ """In-memory entity store with code-based lookup.
31
+
32
+ Entities are dicts with at least: code, status, created_at.
33
+ Additional fields come from create() kwargs.
34
+ """
35
+
36
+ def __init__(self, prefix="entity"):
37
+ self.prefix = prefix
38
+ self._store = {} # entity_id → entity dict
39
+ self._by_code = {} # CODE → entity_id
40
+
41
+ def create(self, **fields):
42
+ """Create a new entity. Returns (entity_id, entity_dict).
43
+
44
+ Always generates: code, status='active', created_at.
45
+ Extra fields are merged in.
46
+ """
47
+ code = generate_code()
48
+ entity_id = generate_entity_id(self.prefix, code)
49
+ entity = {
50
+ "code": code,
51
+ "status": "active",
52
+ "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
53
+ **fields,
54
+ }
55
+ self._store[entity_id] = entity
56
+ self._by_code[code] = entity_id
57
+ return entity_id, entity
58
+
59
+ def get(self, entity_id):
60
+ """Get entity by ID. Returns entity dict or None."""
61
+ return self._store.get(entity_id)
62
+
63
+ def get_by_code(self, code):
64
+ """Look up entity by code. Returns (entity_id, entity_dict) or (None, None)."""
65
+ code = code.upper()
66
+ entity_id = self._by_code.get(code)
67
+ if entity_id and entity_id in self._store:
68
+ return entity_id, self._store[entity_id]
69
+ return None, None
70
+
71
+ def resolve_or_404(self, code):
72
+ """Look up entity by code, raise FastAPI 404 if not found."""
73
+ from fastapi import HTTPException
74
+ entity_id, entity = self.get_by_code(code)
75
+ if not entity_id:
76
+ raise HTTPException(404, f"No {self.prefix} for code {code}")
77
+ return entity_id, entity
78
+
79
+ def update_status(self, entity_id, status):
80
+ """Update an entity's status field."""
81
+ entity = self._store.get(entity_id)
82
+ if entity:
83
+ entity["status"] = status
84
+ return entity
85
+
86
+ def list_active(self):
87
+ """Return list of (entity_id, entity) where status == 'active'."""
88
+ return [
89
+ (eid, e) for eid, e in self._store.items()
90
+ if e.get("status") == "active"
91
+ ]
92
+
93
+ def list_all(self):
94
+ """Return all (entity_id, entity) pairs."""
95
+ return list(self._store.items())