krons 0.1.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.
Files changed (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. krons-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,102 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from ..rule import Rule, RuleParams, RuleQualifier
11
+
12
+ __all__ = ("BaseModelRule",)
13
+
14
+
15
+ def _get_basemodel_params() -> RuleParams:
16
+ """Default params: applies to BaseModel via ANNOTATION qualifier, auto_fix enabled."""
17
+ return RuleParams(
18
+ apply_types={BaseModel},
19
+ apply_fields=set(),
20
+ default_qualifier=RuleQualifier.ANNOTATION,
21
+ auto_fix=True,
22
+ kw={},
23
+ )
24
+
25
+
26
+ class BaseModelRule(Rule):
27
+ """Rule for validating Pydantic BaseModel subclasses.
28
+
29
+ Validates values against expected Pydantic model types with auto-conversion from dict.
30
+ Uses model_validate() for dict-to-model conversion when auto_fix is enabled.
31
+
32
+ Usage:
33
+ rule = BaseModelRule()
34
+ result = await rule.invoke("config", {"name": "test"}, MyModel) # -> MyModel instance
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ params: RuleParams | None = None,
40
+ **kw,
41
+ ):
42
+ """Initialize BaseModel rule.
43
+
44
+ Args:
45
+ params: Custom RuleParams (uses default if None)
46
+ **kw: Additional validation kwargs
47
+ """
48
+ if params is None:
49
+ params = _get_basemodel_params()
50
+ super().__init__(params, **kw)
51
+
52
+ async def validate(self, v: Any, t: type, **kw) -> None:
53
+ """Validate value as a Pydantic model.
54
+
55
+ Args:
56
+ v: Value to validate
57
+ t: Expected BaseModel subclass
58
+
59
+ Raises:
60
+ ValueError: If value cannot be validated as the model type
61
+ """
62
+ if not isinstance(t, type) or not issubclass(t, BaseModel):
63
+ raise ValueError(f"expected_type must be a BaseModel subclass, got {t}")
64
+
65
+ if isinstance(v, t):
66
+ return
67
+
68
+ if isinstance(v, dict):
69
+ try:
70
+ t.model_validate(v)
71
+ return
72
+ except Exception as e:
73
+ raise ValueError(f"Dict validation failed: {e}") from e
74
+
75
+ raise ValueError(f"Cannot validate {type(v).__name__} as {t.__name__}")
76
+
77
+ async def perform_fix(self, v: Any, t: type) -> Any:
78
+ """Attempt to convert value to model using standard validation.
79
+
80
+ Args:
81
+ v: Value to fix
82
+ t: Expected BaseModel subclass
83
+
84
+ Returns:
85
+ Validated model instance
86
+
87
+ Raises:
88
+ ValueError: If conversion fails
89
+ """
90
+ if not isinstance(t, type) or not issubclass(t, BaseModel):
91
+ raise ValueError(f"expected_type must be a BaseModel subclass, got {t}")
92
+
93
+ if isinstance(v, t):
94
+ return v
95
+
96
+ if isinstance(v, dict):
97
+ try:
98
+ return t.model_validate(v)
99
+ except Exception as e:
100
+ raise ValueError(f"Cannot convert dict to {t.__name__}: {e}") from e
101
+
102
+ raise ValueError(f"Cannot convert {type(v).__name__} to {t.__name__}")
@@ -0,0 +1,98 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from typing import Any
5
+
6
+ from ..rule import Rule, RuleParams, RuleQualifier
7
+
8
+ __all__ = ("NumberRule",)
9
+
10
+
11
+ def _get_number_params() -> RuleParams:
12
+ """Default params: applies to int/float via ANNOTATION qualifier, auto_fix enabled."""
13
+ return RuleParams(
14
+ apply_types={int, float},
15
+ apply_fields=set(),
16
+ default_qualifier=RuleQualifier.ANNOTATION,
17
+ auto_fix=True,
18
+ kw={},
19
+ )
20
+
21
+
22
+ class NumberRule(Rule):
23
+ """Rule for validating and converting numeric values.
24
+
25
+ Features:
26
+ - Type checking (int or float)
27
+ - Range constraints (ge, gt, le, lt)
28
+ - Auto-conversion from string/other types to number
29
+
30
+ Usage:
31
+ rule = NumberRule(ge=0.0, le=1.0) # Confidence score
32
+ result = await rule.invoke("confidence", 0.95, float)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ ge: int | float | None = None,
38
+ gt: int | float | None = None,
39
+ le: int | float | None = None,
40
+ lt: int | float | None = None,
41
+ params: RuleParams | None = None,
42
+ **kw,
43
+ ):
44
+ """Initialize number rule.
45
+
46
+ Args:
47
+ ge: Greater than or equal to (>=)
48
+ gt: Greater than (>)
49
+ le: Less than or equal to (<=)
50
+ lt: Less than (<)
51
+ params: Custom RuleParams (uses default if None)
52
+ **kw: Additional validation kwargs
53
+ """
54
+ if params is None:
55
+ params = _get_number_params()
56
+ super().__init__(params, **kw)
57
+ self.ge = ge
58
+ self.gt = gt
59
+ self.le = le
60
+ self.lt = lt
61
+
62
+ async def validate(self, v: Any, t: type, **kw) -> None:
63
+ """Validate that value is a number within constraints.
64
+
65
+ Raises:
66
+ ValueError: If not a number or constraints violated
67
+ """
68
+ if not isinstance(v, (int, float)):
69
+ raise ValueError(f"Invalid number value: expected int or float, got {type(v).__name__}")
70
+
71
+ if self.ge is not None and v < self.ge:
72
+ raise ValueError(f"Number too small: {v} < {self.ge}")
73
+ if self.gt is not None and v <= self.gt:
74
+ raise ValueError(f"Number too small: {v} <= {self.gt}")
75
+ if self.le is not None and v > self.le:
76
+ raise ValueError(f"Number too large: {v} > {self.le}")
77
+ if self.lt is not None and v >= self.lt:
78
+ raise ValueError(f"Number too large: {v} >= {self.lt}")
79
+
80
+ async def perform_fix(self, v: Any, t: type) -> Any:
81
+ """Attempt to convert value to number and re-validate.
82
+
83
+ Returns:
84
+ Numeric value (int or float), validated against constraints
85
+
86
+ Raises:
87
+ ValueError: If conversion or re-validation fails
88
+ """
89
+ fixed: int | float
90
+ try:
91
+ if isinstance(v, str):
92
+ v = v.strip()
93
+ fixed = int(v) if t is int else float(v)
94
+ except (ValueError, TypeError) as e:
95
+ raise ValueError(f"Failed to convert {v} to number") from e
96
+
97
+ await self.validate(fixed, t)
98
+ return fixed
@@ -0,0 +1,140 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import re
5
+ from re import Pattern
6
+ from typing import Any
7
+
8
+ from ..rule import Rule, RuleParams, RuleQualifier
9
+
10
+ __all__ = ("StringRule",)
11
+
12
+ # ReDoS protection: max input length for regex matching
13
+ DEFAULT_REGEX_MAX_INPUT_LENGTH = 10_000
14
+
15
+ # Heuristic ReDoS detection (nested quantifiers). Not exhaustive - for untrusted
16
+ # patterns use google-re2 or regex timeout. Input length limit provides additional mitigation.
17
+ _REDOS_PATTERNS = [
18
+ r"\(\.\*\)\*",
19
+ r"\(\.\+\)\*",
20
+ r"\(\.\*\)\+",
21
+ r"\(\.\+\)\+",
22
+ r"\(\[.*?\]\+\)\+",
23
+ r"\(\[.*?\]\*\)\*",
24
+ ]
25
+ _REDOS_DETECTOR = re.compile("|".join(_REDOS_PATTERNS))
26
+
27
+
28
+ def _get_string_params() -> RuleParams:
29
+ """Default params: applies to str via ANNOTATION qualifier, auto_fix enabled."""
30
+ return RuleParams(
31
+ apply_types={str},
32
+ apply_fields=set(),
33
+ default_qualifier=RuleQualifier.ANNOTATION,
34
+ auto_fix=True,
35
+ kw={},
36
+ )
37
+
38
+
39
+ class StringRule(Rule):
40
+ """Rule for validating and converting string values.
41
+
42
+ Features:
43
+ - Type checking (must be str)
44
+ - Length constraints (min_length, max_length)
45
+ - Pattern matching (regex) with ReDoS protection
46
+ - Auto-conversion from any type to string
47
+
48
+ Usage:
49
+ rule = StringRule(min_length=1, max_length=100, pattern=r'^[A-Za-z]+$')
50
+ result = await rule.invoke("name", "Ocean", str)
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ min_length: int | None = None,
56
+ max_length: int | None = None,
57
+ pattern: str | None = None,
58
+ regex_max_input_length: int = DEFAULT_REGEX_MAX_INPUT_LENGTH,
59
+ params: RuleParams | None = None,
60
+ **kw,
61
+ ):
62
+ """Initialize string rule.
63
+
64
+ Args:
65
+ min_length: Minimum string length (inclusive)
66
+ max_length: Maximum string length (inclusive)
67
+ pattern: Regex pattern to match. Patterns with nested quantifiers
68
+ are rejected to prevent ReDoS attacks.
69
+ regex_max_input_length: Maximum input length for regex matching
70
+ (default 10,000 chars). Inputs exceeding this are rejected.
71
+ params: Custom RuleParams (uses default if None)
72
+ **kw: Additional validation kwargs
73
+
74
+ Raises:
75
+ ValueError: If pattern contains potential ReDoS vulnerabilities
76
+ """
77
+ if params is None:
78
+ params = _get_string_params()
79
+ super().__init__(params, **kw)
80
+ self.min_length = min_length
81
+ self.max_length = max_length
82
+ self.regex_max_input_length = regex_max_input_length
83
+
84
+ self._compiled_pattern: Pattern[str] | None
85
+ if pattern is not None:
86
+ if _REDOS_DETECTOR.search(pattern):
87
+ raise ValueError(
88
+ f"Pattern '{pattern}' contains nested quantifiers that could cause "
89
+ "ReDoS (Regular Expression Denial of Service). Use simpler patterns."
90
+ )
91
+ try:
92
+ self._compiled_pattern = re.compile(pattern)
93
+ except re.error as e:
94
+ raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
95
+ else:
96
+ self._compiled_pattern = None
97
+ self.pattern = pattern
98
+
99
+ async def validate(self, v: Any, t: type, **kw) -> None:
100
+ """Validate that value is a string with correct length and pattern.
101
+
102
+ Raises:
103
+ ValueError: If not a string or constraints violated
104
+ """
105
+ if not isinstance(v, str):
106
+ raise ValueError(f"Invalid string value: expected str, got {type(v).__name__}")
107
+
108
+ if self.min_length is not None and len(v) < self.min_length:
109
+ raise ValueError(
110
+ f"String too short: got {len(v)} characters, minimum {self.min_length}"
111
+ )
112
+ if self.max_length is not None and len(v) > self.max_length:
113
+ raise ValueError(f"String too long: got {len(v)} characters, maximum {self.max_length}")
114
+
115
+ if self._compiled_pattern is not None:
116
+ if len(v) > self.regex_max_input_length:
117
+ raise ValueError(
118
+ f"String too long for regex matching: got {len(v)} characters, "
119
+ f"maximum {self.regex_max_input_length}"
120
+ )
121
+ if not self._compiled_pattern.match(v):
122
+ raise ValueError(f"String does not match required pattern: {self.pattern}")
123
+
124
+ async def perform_fix(self, v: Any, t: type) -> Any:
125
+ """Attempt to convert value to string and re-validate.
126
+
127
+ Returns:
128
+ String representation of value (validated)
129
+
130
+ Raises:
131
+ ValueError: If conversion or re-validation fails
132
+ """
133
+ try:
134
+ fixed = str(v)
135
+ except Exception as e:
136
+ raise ValueError(f"Failed to convert {v} to string") from e
137
+
138
+ # Re-validate the fixed value
139
+ await self.validate(fixed, t)
140
+ return fixed
@@ -0,0 +1,129 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Request context for service operations."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Awaitable
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING, Any, Protocol
12
+ from uuid import UUID, uuid4
13
+
14
+ from kronos.types.base import DataClass
15
+ from kronos.types.identity import ID
16
+
17
+ if TYPE_CHECKING:
18
+ from kronos.session import Branch, Session
19
+
20
+ __all__ = ("QueryFn", "RequestContext")
21
+
22
+
23
+ class QueryFn(Protocol):
24
+ """Protocol for CRUD query functions used by declarative phrases.
25
+
26
+ The query_fn is the bridge between declarative CrudPattern phrases
27
+ and the actual database backend. Implementations MUST use parameterized
28
+ queries to prevent SQL injection.
29
+
30
+ Args:
31
+ table: Validated database table name (alphanumeric + underscores only).
32
+ operation: CRUD operation enum value.
33
+ where: WHERE clause as dict of column -> value. None for insert.
34
+ data: Data dict for insert/update. None for select/delete.
35
+ ctx: The RequestContext (for connection, tenant isolation, etc).
36
+
37
+ Returns:
38
+ Row as dict if found/affected, None otherwise.
39
+ """
40
+
41
+ def __call__(
42
+ self,
43
+ table: str,
44
+ operation: str,
45
+ where: dict[str, Any] | None,
46
+ data: dict[str, Any] | None,
47
+ ctx: RequestContext,
48
+ ) -> Awaitable[dict[str, Any] | None]: ...
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class RequestContext(DataClass):
53
+ """Context for a service request.
54
+
55
+ Carries identity, scope, and metadata through the call chain.
56
+ Extra kwargs become metadata entries accessible as attributes.
57
+
58
+ Core fields (slots):
59
+ name, id, session_id, branch_id, conn, query_fn, now
60
+
61
+ Extension fields (metadata, accessible via attribute access):
62
+ Any kwarg passed to __init__ is stored in metadata and accessible
63
+ as ctx.field_name. Common extensions:
64
+ - tenant_id: Tenant scope (auto-added to WHERE clauses)
65
+ - actor_id: Who is performing the action
66
+ - subject_id: Person subject of the operation
67
+ - correlation_id: Trace ID linking related operations
68
+ - causation_id: Parent operation that caused this
69
+ - charter: Active governance document
70
+ - jurisdictions: Jurisdiction scope tuple
71
+
72
+ Usage:
73
+ ctx = RequestContext(
74
+ name="consent.grant",
75
+ conn=connection,
76
+ tenant_id=tenant_uuid,
77
+ actor_id=actor_uuid,
78
+ subject_id=subject_uuid,
79
+ correlation_id=trace_id,
80
+ )
81
+ ctx.tenant_id # -> tenant_uuid (from metadata)
82
+ ctx.subject_id # -> subject_uuid (from metadata)
83
+ """
84
+
85
+ name: str
86
+ id: UUID = field(default_factory=uuid4)
87
+ session_id: ID[Session] | None = None
88
+ branch_id: ID[Branch] | None = None
89
+ metadata: dict[str, Any] = field(default_factory=dict)
90
+ conn: Any | None = None
91
+ query_fn: QueryFn | None = None
92
+ now: datetime | None = None
93
+
94
+ def __init__(
95
+ self,
96
+ name: str,
97
+ session_id: ID[Session] | None = None,
98
+ branch_id: ID[Branch] | None = None,
99
+ id: UUID | None = None,
100
+ conn: Any | None = None,
101
+ query_fn: QueryFn | None = None,
102
+ now: datetime | None = None,
103
+ **kwargs: Any,
104
+ ):
105
+ self.name = name
106
+ self.id = id or uuid4()
107
+ self.session_id = session_id
108
+ self.branch_id = branch_id
109
+ self.conn = conn
110
+ self.query_fn = query_fn
111
+ self.now = now
112
+ self.metadata = kwargs
113
+
114
+ def __getattr__(self, name: str) -> Any:
115
+ """Look up unknown attributes in metadata.
116
+
117
+ Called only when normal attribute lookup fails (slots miss).
118
+ Raises AttributeError for keys not present in metadata so that
119
+ hasattr() works correctly.
120
+ """
121
+ if name.startswith("_"):
122
+ raise AttributeError(name)
123
+ metadata = object.__getattribute__(self, "metadata")
124
+ try:
125
+ return metadata[name]
126
+ except KeyError:
127
+ raise AttributeError(
128
+ f"'{type(self).__name__}' has no attribute '{name}'"
129
+ ) from None
@@ -0,0 +1,80 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Policy protocols and types.
5
+
6
+ Defines contracts for policy resolution and evaluation.
7
+ Implementations provided by domain libs (e.g., canon-core).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from kronos.enforcement.context import RequestContext
17
+ from kronos.specs.catalog._enforcement import EnforcementLevel
18
+ from kronos.types.base import DataClass
19
+
20
+ __all__ = (
21
+ "EnforcementLevel",
22
+ "PolicyEngine",
23
+ "PolicyResolver",
24
+ "ResolvedPolicy",
25
+ )
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class ResolvedPolicy(DataClass):
30
+ """A policy resolved for evaluation.
31
+
32
+ Returned by PolicyResolver.resolve(). Contains policy ID and
33
+ any resolution metadata needed by the engine.
34
+ """
35
+
36
+ policy_id: str
37
+ enforcement: str = EnforcementLevel.HARD_MANDATORY.value
38
+ metadata: dict[str, Any] = field(default_factory=dict)
39
+
40
+
41
+ @runtime_checkable
42
+ class PolicyEngine(Protocol):
43
+ """Abstract policy evaluation engine.
44
+
45
+ kron defines the contract. Implementations:
46
+ - canon-core: OPAEngine (Rego/Regorus evaluation)
47
+ - Testing: MockPolicyEngine
48
+ """
49
+
50
+ async def evaluate(
51
+ self,
52
+ policy_id: str,
53
+ input_data: dict[str, Any],
54
+ **options: Any,
55
+ ) -> Any:
56
+ """Evaluate a single policy against input."""
57
+ ...
58
+
59
+ async def evaluate_batch(
60
+ self,
61
+ policy_ids: Sequence[str],
62
+ input_data: dict[str, Any],
63
+ **options: Any,
64
+ ) -> list[Any]:
65
+ """Evaluate multiple policies."""
66
+ ...
67
+
68
+
69
+ @runtime_checkable
70
+ class PolicyResolver(Protocol):
71
+ """Resolves which policies apply to a given context.
72
+
73
+ kron defines the contract. Implementations:
74
+ - canon-core: CharteredResolver (charter-based resolution)
75
+ - Testing: MockPolicyResolver, StaticPolicyResolver
76
+ """
77
+
78
+ def resolve(self, ctx: RequestContext) -> Sequence[ResolvedPolicy]:
79
+ """Determine applicable policies for context."""
80
+ ...
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .rule import Rule
10
+
11
+ __all__ = ("RuleRegistry", "get_default_registry")
12
+
13
+ _default_registry: RuleRegistry | None = None
14
+
15
+
16
+ class RuleRegistry:
17
+ """Registry mapping types to Rule classes/instances.
18
+
19
+ Provides automatic Rule assignment based on Spec.base_type.
20
+ Supports inheritance-based lookup (subclasses inherit parent rules).
21
+
22
+ Usage:
23
+ registry = RuleRegistry()
24
+ registry.register(str, StringRule(min_length=1))
25
+ registry.register(int, NumberRule(ge=0))
26
+
27
+ rule = registry.get_rule(str) # Returns StringRule instance
28
+ rule = registry.get_rule(MyStr) # Returns StringRule (inherited)
29
+ """
30
+
31
+ def __init__(self):
32
+ """Initialize empty registry."""
33
+ self._type_rules: dict[type, Rule] = {}
34
+ self._name_rules: dict[str, Rule] = {}
35
+
36
+ def register(
37
+ self,
38
+ key: type | str,
39
+ rule: Rule,
40
+ *,
41
+ replace: bool = False,
42
+ ) -> None:
43
+ """Register a Rule for a type or field name.
44
+
45
+ Args:
46
+ key: Type or field name to register
47
+ rule: Rule instance to use
48
+ replace: Allow replacing existing registration
49
+
50
+ Raises:
51
+ ValueError: If key already registered and replace=False
52
+ """
53
+ if isinstance(key, str):
54
+ if key in self._name_rules and not replace:
55
+ raise ValueError(f"Rule already registered for field '{key}'")
56
+ self._name_rules[key] = rule
57
+ else:
58
+ if key in self._type_rules and not replace:
59
+ raise ValueError(f"Rule already registered for type {key}")
60
+ self._type_rules[key] = rule
61
+
62
+ def get_rule(
63
+ self,
64
+ base_type: type | None = None,
65
+ field_name: str | None = None,
66
+ ) -> Rule | None:
67
+ """Get Rule for a type or field name.
68
+
69
+ Priority:
70
+ 1. Exact field name match
71
+ 2. Exact type match
72
+ 3. Inheritance-based type match (check base classes)
73
+
74
+ Args:
75
+ base_type: Type to look up
76
+ field_name: Field name to look up
77
+
78
+ Returns:
79
+ Rule instance or None if not found
80
+ """
81
+ if field_name and field_name in self._name_rules:
82
+ return self._name_rules[field_name]
83
+
84
+ if base_type and base_type in self._type_rules:
85
+ return self._type_rules[base_type]
86
+
87
+ if base_type:
88
+ for registered_type, rule in self._type_rules.items():
89
+ try:
90
+ if issubclass(base_type, registered_type):
91
+ return rule
92
+ except TypeError:
93
+ continue
94
+
95
+ return None
96
+
97
+ def has_rule(self, key: type | str) -> bool:
98
+ """Check if a rule is registered for type or name."""
99
+ if isinstance(key, str):
100
+ return key in self._name_rules
101
+ return key in self._type_rules or any(
102
+ issubclass(key, t) for t in self._type_rules if isinstance(key, type)
103
+ )
104
+
105
+ def list_types(self) -> list[type]:
106
+ """List all registered types."""
107
+ return list(self._type_rules.keys())
108
+
109
+ def list_names(self) -> list[str]:
110
+ """List all registered field names."""
111
+ return list(self._name_rules.keys())
112
+
113
+
114
+ def get_default_registry() -> RuleRegistry:
115
+ """Get the default rule registry with standard rules.
116
+
117
+ Standard mappings:
118
+ str -> StringRule
119
+ int -> NumberRule
120
+ float -> NumberRule
121
+ bool -> BooleanRule
122
+ dict -> MappingRule
123
+ BaseModel -> BaseModelRule (catches all Pydantic models)
124
+
125
+ Returns:
126
+ RuleRegistry with default rules registered
127
+ """
128
+ global _default_registry
129
+
130
+ if _default_registry is None:
131
+ from pydantic import BaseModel
132
+
133
+ from .common.boolean import BooleanRule
134
+ from .common.mapping import MappingRule
135
+ from .common.model import BaseModelRule
136
+ from .common.number import NumberRule
137
+ from .common.string import StringRule
138
+
139
+ _default_registry = RuleRegistry()
140
+ _default_registry.register(str, StringRule())
141
+ _default_registry.register(int, NumberRule())
142
+ _default_registry.register(float, NumberRule())
143
+ _default_registry.register(bool, BooleanRule())
144
+ _default_registry.register(dict, MappingRule())
145
+ _default_registry.register(BaseModel, BaseModelRule())
146
+
147
+ return _default_registry
148
+
149
+
150
+ def reset_default_registry() -> None:
151
+ """Reset the default registry (for testing)."""
152
+ global _default_registry
153
+ _default_registry = None