Changeset 5c72356 in flexoentity
- Timestamp:
- 11/02/25 18:49:14 (2 months ago)
- Branches:
- master
- Children:
- bf30018
- Parents:
- 8aa20c7
- Files:
-
- 5 edited
-
flexoentity/domain.py (modified) (2 diffs)
-
flexoentity/flexo_entity.py (modified) (12 diffs)
-
tests/conftest.py (modified) (5 diffs)
-
tests/test_id_lifecycle.py (modified) (4 diffs)
-
tests/test_id_stress.py (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
flexoentity/domain.py
r8aa20c7 r5c72356 1 1 from dataclasses import dataclass 2 from flexoentity.flexo_entity import Flex oEntity, EntityType, EntityState2 from flexoentity.flexo_entity import FlexOID, FlexoEntity, EntityType, EntityState 3 3 4 4 … … 12 12 @classmethod 13 13 def default(cls): 14 return cls(domain="GEN" , entity_type=EntityType.DOMAIN, state=EntityState.DRAFT)14 return cls(domain="GEN") 15 15 16 16 def __post_init__(self): 17 17 super().__post_init__() 18 18 19 # Generate FlexOID only if missing 20 if not getattr(self, "flexo_id", None): 21 domain_code = self.domain or "GEN" 22 self.flexo_id = FlexOID.safe_generate( 23 domain=domain_code, 24 entity_type=EntityType.DOMAIN.value, # 'D' 25 estate=EntityState.DRAFT.value, # 'D' 26 text=self.text_seed, 27 version=1, 28 ) 29 30 31 19 32 @property 20 33 def text_seed(self) -> str: -
flexoentity/flexo_entity.py
r8aa20c7 r5c72356 1 1 """ 2 versioned_entity.py — Shared base class for all Flex-O entities. 3 Integrates with hardened id_factory.py and content fingerprint tracking. 2 flexo_entity.py - I represent the mutable counterpart of the immutable FlexOID. 3 4 I exist to connect an entity’s immutable identity with its mutable provenance 5 and content. My FlexOID encodes what an entity *is* - its domain, type, 6 version, and lifecycle state - while I record *who* created it, *who* owns it 7 now, and *where* it came from. 8 9 Structure 10 ────────── 11 - My `flexo_id` is the canonical source of truth. 12 It deterministically encodes: 13 - domain (prefix) 14 - entity type (single letter, e.g. I, C, M) 15 - date + hash (prefix stability) 16 - version (numeric counter) 17 - state (single capital letter) 18 19 - My derived properties (`state`, `entity_type`, `version`) read from that ID. 20 They are never stored separately, so I cannot become inconsistent. 21 22 - My attributes `origin`, `originator_id`, and `owner_id` express authorship 23 and workflow context: 24 - `origin` links to the FlexOID of the entity I was derived from 25 - `originator_id` identifies the original creator (UUID) 26 - `owner_id` identifies the current responsible user or process (UUID) 27 28 - My optional `domain` field is a semantic hint, useful for filtering or 29 multi-tenant setups, but the canonical domain remains encoded in my ID. 30 31 Design principles 32 ────────────────── 33 I separate immutable identity from mutable context: 34 35 ┌────────────────────────────┐ 36 │ FlexOID │ 37 │ – who I am │ 38 │ – lifecycle state │ 39 └────────────▲───────────────┘ 40 │ 41 ┌────────────┴───────────────┐ 42 │ FlexoEntity (this module) │ 43 │ – who created or owns me │ 44 │ – where I came from │ 45 │ – what content I hold │ 46 └────────────────────────────┘ 47 48 This means: 49 - My identity never changes; only my content or ownership does. 50 - My lifecycle transitions (approve, sign, publish) are represented by new 51 FlexOIDs, not by mutating state attributes. 52 - My audit trail and access control live in the provenance layer, not the ID. 53 54 I am the living object behind a FlexOID — mutable, traceable, and accountable. 4 55 """ 56 5 57 import json 6 import re7 58 from uuid import UUID 8 59 from enum import Enum 9 from dataclasses import dataclass, field 60 from dataclasses import dataclass, field, replace 10 61 from typing import Optional 11 62 from abc import ABC, abstractmethod … … 67 118 @dataclass(kw_only=True) 68 119 class FlexoEntity(ABC): 120 """ 121 I represent a mutable entity identified by an immutable FlexOID. 122 123 My `flexo_id` is the single source of truth for what I am: 124 - it encodes my domain, entity type, version, and lifecycle state. 125 I never store those values separately, but derive them on demand. 126 127 I extend the raw identity with provenance and authorship data: 128 - `origin` → the FlexOID of the entity I was derived from 129 - `originator_id` → the UUID of the first creator 130 - `owner_id` → the UUID of the current responsible person or system 131 - `domain` → optional semantic domain hint (for filtering or indexing) 132 133 I am designed to be traceable and reproducible: 134 - My identity (`flexo_id`) never mutates. 135 - My lifecycle transitions are represented by new FlexOIDs. 136 - My provenance (origin, originator_id, owner_id) can change over time 137 as the entity moves through workflows or ownership transfers. 138 139 Behavior 140 ───────── 141 - `state` and `entity_type` are read-only views derived from my FlexOID. 142 - `to_dict()` and `from_dict()` serialize me with human-readable fields 143 while preserving the canonical FlexOID. 144 - Subclasses (such as questions, media items, or catalogs) extend me with 145 domain-specific content and validation, but not with new identity rules. 146 147 I am the living, editable layer that connects identity with accountability. 148 """ 149 69 150 domain: str 70 entity_type: EntityType71 151 subtype: str = "GENERIC" 72 state: EntityState73 152 flexo_id: Optional[FlexOID] = field(default=None) 74 153 fingerprint: str = field(default_factory=str) … … 77 156 origin: Optional[str] = field(default=None) 78 157 158 def with_new_owner(self, new_owner: UUID): 159 """Return a clone of this entity with a different owner.""" 160 copy = replace(self) 161 copy.owner_id = new_owner 162 return copy 163 79 164 @property 80 165 @abstractmethod … … 83 168 raise NotImplementedError("Subclasses must define text_seed property") 84 169 170 @property 171 def state(self) -> EntityState: 172 return EntityState(self.flexo_id.state_code) 173 174 @property 175 def entity_type(self) -> EntityType: 176 return EntityType(self.flexo_id.entity_type) 177 85 178 def canonical_seed(self) -> str: 86 179 return canonical_seed(self.text_seed) … … 98 191 def __post_init__(self): 99 192 """ 100 Generate ID and content fingerprint. 101 102 All entities must carry a `.domain` attribute exposing a domain code string. 103 This may be a `Domain` instance or a temporary wrapper used by the `Domain` 104 class itself to avoid circular initialization. 105 """ 106 107 self.flexo_id = FlexOID.safe_generate(self.domain_code(), 108 self.entity_type.value, 109 self.state.value, 110 self.text_seed, 111 1) 112 seed = canonical_seed(self.text_seed) 113 self.fingerprint = hashlib.blake2s(seed.encode("utf-8"), digest_size=8).hexdigest().upper() 193 Optionally generate a new FlexOID and fingerprint if none exist. 194 195 I check for subclass attributes `ENTITY_TYPE` and `INITIAL_STATE`. 196 If they exist, I use them to generate a draft FlexOID. 197 Otherwise, I skip ID generation — leaving it to the subclass. 198 """ 199 200 # If already has a FlexOID, do nothing 201 if getattr(self, "flexo_id", None): 202 return 203 204 # Skip if subclass doesn’t declare ENTITY_TYPE / INITIAL_STATE 205 etype = getattr(self.__class__, "ENTITY_TYPE", None) 206 if not etype: 207 # No info available, do nothing 208 return 209 210 # Generate a new draft FlexOID 211 self.flexo_id = FlexOID.safe_generate( 212 self.domain_code(), 213 etype.value, 214 EntityState.DRAFT.value, 215 self.text_seed, 216 1, 217 ) 218 219 # Compute fingerprint 220 self.fingerprint = self._compute_fingerprint() 114 221 115 222 def __str__(self): … … 135 242 abbrev, fullname = (lambda p: (p[0], p[1] if len(p) > 1 else ""))(domain.split("_", 1)) 136 243 domain_obj = Domain( 137 domain=abbrev, 138 entity_type=EntityType.DOMAIN, 139 state=EntityState.DRAFT, # default when reconstructing context 244 domain=abbrev 140 245 ) 141 246 obj = cls( 142 247 domain=domain_obj, 143 entity_type=EntityType[data["entity_type"]], 144 state=EntityState[data["state"]], 145 ) 146 obj.flexo_id = FlexOID.from_string(data["flexo_id"]) 248 ) 249 obj.flexo_id = FlexOID(data["flexo_id"]) 147 250 obj.fingerprint = data.get("fingerprint", "") 148 251 obj.origin = data.get("origin") … … 207 310 # special case: marking obsolete 208 311 if target_state == EntityState.OBSOLETE: 209 self.flexo_id = FlexOID.with_state(self.flexo_id, "O") 210 self.state = target_state 312 self.flexo_id = self.flexo_id.with_state(EntityState.OBSOLETE.value) 211 313 return 212 314 … … 258 360 self.origin = self.flexo_id # optional: keep audit trail 259 361 self.flexo_id = new_fid 260 self.state = EntityState.APPROVED261 362 return self 262 363 raise ValueError("Only drafts can be approved") 263 364 365 264 366 def sign(self): 265 self._transition(EntityState.APPROVED_AND_SIGNED) 367 """ 368 Mark entity as approved and signed without bumping version. 369 Only changes the state letter in the FlexOID. 370 """ 371 if self.state != EntityState.APPROVED: 372 raise ValueError("Only approved entities can be signed.") 373 374 # Change only the trailing state letter (A → S) 375 self.flexo_id = self.flexo_id.with_state(EntityState.APPROVED_AND_SIGNED.value) 376 return self 266 377 267 378 def publish(self): … … 279 390 ) 280 391 281 new_version = self.flexo_id.version + 1282 392 new_fid = FlexOID.safe_generate( 283 393 self.domain_code(), … … 285 395 EntityState.PUBLISHED.value, 286 396 self.text_seed, 287 version= new_version397 version=self.version 288 398 ) 289 399 290 400 self.origin = self.flexo_id 291 401 self.flexo_id = new_fid 292 self.state = EntityState.PUBLISHED293 402 return self 294 403 … … 302 411 if self.state != EntityState.OBSOLETE: 303 412 self._transition(EntityState.OBSOLETE) 413 return self 304 414 305 415 def clone_new_base(self): … … 309 419 self.domain_code(), 310 420 self.entity_type.value, 311 self.state.value,421 EntityState.DRAFT.value, 312 422 self.text_seed, 313 423 ) 314 self.state = EntityState.DRAFT424 return self 315 425 316 426 # ─────────────────────────────────────────────────────────────── 317 427 # Integrity verification 318 428 # ─────────────────────────────────────────────────────────────── 429 @staticmethod 430 def debug_integrity(entity): 431 """ 432 Return a dict with all values used by verify_integrity so we can see 433 exactly why a check passes/fails. 434 """ 435 info = {} 436 try: 437 fid = entity.flexo_id 438 info["fid"] = str(fid) 439 info["fid_domain"] = fid.domain 440 info["fid_type"] = fid.entity_type 441 info["fid_state"] = fid.state_code 442 info["fid_hash_part"] = fid.hash_part 443 info["version"] = fid.version 444 445 # Derived views 446 info["derived_domain_code"] = getattr(entity, "domain_code")() if hasattr(entity, "domain_code") else None 447 info["derived_entity_type_value"] = entity.entity_type.value 448 info["derived_state_value"] = entity.state.value 449 450 # Seeds & fingerprints 451 info["text_seed"] = entity.text_seed 452 exp_fp = entity._compute_fingerprint() 453 info["expected_fingerprint"] = exp_fp 454 info["stored_fingerprint"] = getattr(entity, "fingerprint", None) 455 456 # Expected ID hash from current seed (should match fid.hash_part if content didn't change) 457 exp_hash = FlexOID._blake_hash(canonical_seed(f"{fid.domain}:{fid.entity_type}:{entity.text_seed}")) 458 info["expected_id_hash_from_seed"] = exp_hash 459 460 # Quick mismatches 461 info["mismatch_fp"] = (info["stored_fingerprint"] != exp_fp) 462 info["mismatch_hash"] = (fid.hash_part != exp_hash) 463 info["mismatch_type"] = (entity.entity_type.value != fid.entity_type) 464 info["mismatch_state"] = (entity.state.value != fid.state_code) 465 info["mismatch_domain"] = (info["derived_domain_code"] is not None and info["derived_domain_code"] != fid.domain) 466 except Exception as e: 467 info["error"] = repr(e) 468 return info 319 469 320 470 @staticmethod 321 471 def verify_integrity(entity) -> bool: 322 """Verify that an entity’s content fingerprint matches its actual content.""" 323 expected_fp = hashlib.blake2s( 324 canonical_seed(entity.text_seed).encode("utf-8"), digest_size=8 325 ).hexdigest().upper() 326 return expected_fp == entity.fingerprint 472 """ 473 Verify that an entity's FlexOID, state, and fingerprint are consistent. 474 475 Returns False if: 476 - flexo_id format is invalid 477 - derived state/type mismatch the ID 478 - fingerprint doesn't match the canonical text seed 479 """ 480 try: 481 if not isinstance(entity.flexo_id, FlexOID): 482 return False 483 484 # Validate domain and ID coherence 485 if entity.domain and entity.domain_code() != entity.flexo_id.domain: 486 return False 487 if entity.entity_type.value != entity.flexo_id.entity_type: 488 return False 489 if entity.state.value != entity.flexo_id.state_code: 490 return False 491 492 # --- Fingerprint validation --- 493 if hasattr(entity, "fingerprint") and entity.fingerprint: 494 seed = canonical_seed(entity.text_seed) 495 expected_fp = entity._compute_fingerprint() 496 if expected_fp != entity.fingerprint: 497 return False 498 499 return True 500 501 except Exception as e: 502 return False 327 503 328 504 def allowed_transitions(self) -> list[str]: -
tests/conftest.py
r8aa20c7 r5c72356 4 4 from dataclasses import dataclass, field 5 5 from typing import List 6 from flexoentity import Flex oEntity, EntityType, EntityState, Domain6 from flexoentity import FlexOID, FlexoEntity, EntityType, EntityState, Domain 7 7 8 8 @pytest.fixture … … 37 37 class SingleChoiceQuestion(FlexoEntity): 38 38 """A minimal stub to test FlexoEntity integration.""" 39 ENTITY_TYPE = EntityType.ITEM 40 39 41 text: str = "" 40 42 options: List[AnswerOption] = field(default_factory=list) 41 43 44 def __post_init__(self): 45 # If no FlexOID yet, generate a draft ID now. 46 if not getattr(self, "flexo_id", None): 47 domain_code = ( 48 self.domain.domain if isinstance(self.domain, Domain) else "GEN" 49 ) 50 self.flexo_id = FlexOID.safe_generate( 51 domain=domain_code, 52 entity_type=EntityType.ITEM.value, # 'I' 53 estate=EntityState.DRAFT.value, # 'D' 54 text=self.text_seed or self.text, 55 version=1, 56 ) 42 57 43 58 @classmethod 44 59 def default(cls): 45 return cls(domain=Domain(domain="GEN", 46 entity_type=EntityType.DOMAIN, 47 state=EntityState.DRAFT), 48 state=EntityState.DRAFT, entity_type=EntityType.ITEM) 60 return cls(domain=Domain(domain="GEN")) 49 61 50 62 def to_dict(self): … … 68 80 @classmethod 69 81 def from_dict(cls, data): 70 obj = cls( 82 obj = cls(domain=Domain(domain="GEN"), 71 83 text=data.get("text", ""), 72 84 options=[AnswerOption.from_dict(o) for o in data.get("options", [])], … … 74 86 # restore FlexoEntity core fields 75 87 obj.domain = data.get("domain") 76 obj.entity_type = EntityType[data.get("etype")] if "etype" in data else EntityType.ITEM77 obj.state = EntityState[data.get("state")] if "state" in data else EntityState.DRAFT78 88 if "flexo_id" in data: 79 from flexoentity import FlexOID80 89 obj.flexo_id = FlexOID.parsed(data["flexo_id"]) 81 90 return obj … … 87 96 @pytest.fixture 88 97 def sample_question(): 89 returnSingleChoiceQuestion(domain=Domain.default(),90 text="What is 2 + 2?",91 options=[],92 entity_type=EntityType.ITEM,93 state=EntityState.DRAFT)98 q = SingleChoiceQuestion(domain=Domain.default(), 99 text="What is 2 + 2?", 100 options=[]) 101 q._update_fingerprint() 102 return q -
tests/test_id_lifecycle.py
r8aa20c7 r5c72356 20 20 assert q.flexo_id.version == 1 21 21 22 23 def test_signing_bumps_version(sample_question): 22 def test_signing_does_not_bump_version(sample_question): 24 23 q = sample_question 25 24 q.approve() 26 v_before = str(q.flexo_id)25 before = q.flexo_id 27 26 q.sign() 27 after = q.flexo_id 28 29 # state changed 28 30 assert q.state == EntityState.APPROVED_AND_SIGNED 29 assert str(q.flexo_id) != v_before 31 32 # version unchanged 33 assert before.version == after.version 34 35 # only suffix letter differs 36 assert before.prefix == after.prefix 37 assert before.state_code == "A" 38 assert after.state_code == "S" 30 39 31 40 32 def test_publish_ bumps_version(sample_question):41 def test_publish_does_not_bump_version(sample_question): 33 42 q = sample_question 34 43 q.approve() … … 37 46 q.publish() 38 47 assert q.state == EntityState.PUBLISHED 39 assert q.flexo_id.version == v_before + 148 assert q.flexo_id.version == v_before 40 49 41 50 … … 70 79 # simulate tampering 71 80 q.text = "Tampered text" 81 print(FlexoEntity.debug_integrity(q)) 72 82 assert not FlexoEntity.verify_integrity(q) 73 83 … … 112 122 for _ in range(FlexOID.MAX_VERSION - 1): 113 123 q.bump_version() 124 125 # Next one must raise 114 126 with pytest.raises(RuntimeError, match="mark obsolete"): 115 q. sign()127 q.bump_version() -
tests/test_id_stress.py
r8aa20c7 r5c72356 82 82 83 83 84 def test_version_ceiling_enforcement(sample_question):85 """Simulate approaching @999 to trigger obsolescence guard."""86 q = sample_question87 q.approve()88 89 # artificially bump version number to near ceiling90 q.flexo_id = FlexOID.from_oid_and_version(q.flexo_id, 998)91 92 # 998 → 999 is allowed93 q.sign()94 assert q.flexo_id.version == 99995 96 # 999 → 1000 should raise RuntimeError97 with pytest.raises(ValueError):98 q.publish()99 100 101 84 def test_massive_lifecycle_simulation(sample_question): 102 85 """
Note:
See TracChangeset
for help on using the changeset viewer.
