weaver-kernel 0.6.0__tar.gz → 0.7.0__tar.gz

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 (75) hide show
  1. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/CHANGELOG.md +45 -0
  2. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/PKG-INFO +1 -1
  3. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/architecture.md +45 -2
  4. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/pyproject.toml +1 -1
  5. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/__init__.py +9 -0
  6. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/errors.py +18 -1
  7. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/kernel.py +22 -0
  8. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/models.py +116 -1
  9. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/policy.py +132 -20
  10. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/policy_dsl.py +162 -4
  11. weaver_kernel-0.7.0/src/agent_kernel/policy_reasons.py +92 -0
  12. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_kernel.py +65 -0
  13. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_models.py +89 -0
  14. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_policy.py +463 -0
  15. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/.claude/CLAUDE.md +0 -0
  16. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/.github/copilot-instructions.md +0 -0
  17. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/.github/workflows/ci.yml +0 -0
  18. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/.github/workflows/publish.yml +0 -0
  19. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/.gitignore +0 -0
  20. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/AGENTS.md +0 -0
  21. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/CONTRIBUTING.md +0 -0
  22. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/LICENSE +0 -0
  23. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/Makefile +0 -0
  24. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/README.md +0 -0
  25. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/RELEASE.md +0 -0
  26. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/agent-context/architecture.md +0 -0
  27. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/agent-context/invariants.md +0 -0
  28. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/agent-context/lessons-learned.md +0 -0
  29. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/agent-context/review-checklist.md +0 -0
  30. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/agent-context/workflows.md +0 -0
  31. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/capabilities.md +0 -0
  32. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/context_firewall.md +0 -0
  33. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/integrations.md +0 -0
  34. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/docs/security.md +0 -0
  35. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/examples/basic_cli.py +0 -0
  36. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/examples/billing_demo.py +0 -0
  37. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/examples/http_driver_demo.py +0 -0
  38. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/examples/policies/default.toml +0 -0
  39. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/examples/policies/default.yaml +0 -0
  40. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/adapters/__init__.py +0 -0
  41. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/adapters/_base.py +0 -0
  42. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/adapters/anthropic.py +0 -0
  43. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/adapters/openai.py +0 -0
  44. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/__init__.py +0 -0
  45. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/base.py +0 -0
  46. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/http.py +0 -0
  47. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/mcp.py +0 -0
  48. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/mcp_support.py +0 -0
  49. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/memory.py +0 -0
  50. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/enums.py +0 -0
  51. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/__init__.py +0 -0
  52. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/budget_manager.py +0 -0
  53. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/budgets.py +0 -0
  54. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/redaction.py +0 -0
  55. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/summarize.py +0 -0
  56. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/token_counting.py +0 -0
  57. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/transform.py +0 -0
  58. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/handles.py +0 -0
  59. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/py.typed +0 -0
  60. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/registry.py +0 -0
  61. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/router.py +0 -0
  62. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/tokens.py +0 -0
  63. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/src/agent_kernel/trace.py +0 -0
  64. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/conftest.py +0 -0
  65. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_adapters.py +0 -0
  66. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_drivers.py +0 -0
  67. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_firewall.py +0 -0
  68. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_handles.py +0 -0
  69. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_logging.py +0 -0
  70. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_mcp_driver.py +0 -0
  71. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_redaction.py +0 -0
  72. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_registry.py +0 -0
  73. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_router.py +0 -0
  74. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_tokens.py +0 -0
  75. {weaver_kernel-0.6.0 → weaver_kernel-0.7.0}/tests/test_trace.py +0 -0
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-05-20
11
+
12
+ ### Added
13
+ - Structured intent and scope metadata on `CapabilityRequest`: new optional
14
+ `intent: str | None` and `scope: dict[str, Any]` fields let policy engines
15
+ authorize based on machine-readable intent and scope alongside the existing
16
+ free-text `goal`. `DeclarativePolicyEngine` rules can match on these via new
17
+ `intent: [...]` and `scope: {key: value}` clauses in YAML/TOML policy files.
18
+ Intent-aware allow rules fail closed for legacy callers that don't set an
19
+ intent. (#72)
20
+ - Structured policy decision trace (`PolicyDecisionTrace` + `PolicyTraceStep`):
21
+ both built-in policy engines now attach a step-by-step trace to every
22
+ `PolicyDecision` (allow and deny paths). Each step records the rule
23
+ considered, the outcome (`matched`/`skipped`/`denied`/`allowed`/
24
+ `constraint_applied`), a human-readable detail, and — for terminal
25
+ steps — the stable reason code. Traces echo `intent` and `scope_keys`
26
+ (scope dimension names only — values redacted) from the request and contain
27
+ no raw argument values. `DryRunResult.policy_decision`
28
+ also carries a synthesized single-step trace. (#73)
29
+ - Stable machine-readable denial reason codes: new `DenialReason` and
30
+ `AllowReason` enums in `agent_kernel.policy_reasons` (also exported as
31
+ `from agent_kernel import DenialReason, AllowReason`). Every built-in
32
+ denial path on `DefaultPolicyEngine` and `DeclarativePolicyEngine` populates
33
+ `PolicyDecision.reason_code`, `DenialExplanation.reason_code`,
34
+ `FailedCondition.reason_code`, and `PolicyDenied.reason_code`. Tests should
35
+ assert on these codes instead of matching the human-readable `reason` /
36
+ `narrative` strings, which remain part of the API but may evolve for
37
+ clarity. Codes: `missing_role`, `missing_tenant_attribute`,
38
+ `missing_attribute`, `insufficient_justification`, `invalid_constraint`,
39
+ `rate_limited`, `no_matching_rule`, `explicit_deny_rule`,
40
+ `intent_not_allowed`, `scope_not_allowed`; allow-side: `default_policy_allow`,
41
+ `rule_allow`, `default_fallthrough_allow`. (#77)
42
+ - New public exports: `AllowReason`, `DenialReason`, `PolicyDecisionTrace`,
43
+ `PolicyTraceStep`.
44
+
45
+ ### Changed
46
+ - `PolicyDecision` gained optional `reason_code: str | None` and
47
+ `trace: PolicyDecisionTrace | None` fields (both default `None` so
48
+ third-party engines that don't populate them keep working).
49
+ - `DenialExplanation` and `FailedCondition` gained optional `reason_code`
50
+ fields populated by both built-in engines on every denial path.
51
+ - `PolicyDenied(reason_code=...)` keyword argument: the exception now carries
52
+ a `reason_code` attribute so callers can branch on a stable code without
53
+ matching the human-readable message.
54
+
10
55
  ## [0.6.0] - 2026-05-19
11
56
 
12
57
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weaver-kernel
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Capability-based security kernel for AI agents operating in large tool ecosystems
5
5
  Project-URL: Homepage, https://github.com/dgenio/agent-kernel
6
6
  Project-URL: Repository, https://github.com/dgenio/agent-kernel
@@ -54,11 +54,54 @@ Both built-in engines satisfy `ExplainingPolicyEngine`:
54
54
  5. **SECRETS** — requires role `admin|secrets_reader` + `justification ≥ 15 chars`
55
55
  6. **max_rows** — 50 (user), 500 (service)
56
56
  7. **Rate limiting** — sliding-window per `(principal_id, capability_id)` (60 READ / 10 WRITE / 2 DESTRUCTIVE per 60s; service role gets 10×)
57
- - **`DeclarativePolicyEngine`** — loads rules from a YAML or TOML file (or a plain dict). Supports `safety_class`, `sensitivity`, `roles`, `attributes`, and `min_justification` match conditions; `allow`/`deny` actions; per-rule `constraints` merged into the resulting `PolicyDecision`; configurable `default` action. Rules are evaluated top-down with first-match-wins. `pyyaml` and `tomli` are optional dependencies — `import agent_kernel` works without them; calling `from_yaml`/`from_toml` without the parser raises `PolicyConfigError` with an install hint.
57
+ - **`DeclarativePolicyEngine`** — loads rules from a YAML or TOML file (or a plain dict). Supports `safety_class`, `sensitivity`, `roles`, `attributes`, `min_justification`, `intent`, and `scope` match conditions; `allow`/`deny` actions; per-rule `constraints` merged into the resulting `PolicyDecision`; configurable `default` action. Rules are evaluated top-down with first-match-wins. `pyyaml` and `tomli` are optional dependencies — `import agent_kernel` works without them; calling `from_yaml`/`from_toml` without the parser raises `PolicyConfigError` with an install hint.
58
+
59
+ #### Intent and scope on requests
60
+
61
+ `CapabilityRequest` carries optional structured metadata alongside its free-text `goal`:
62
+
63
+ - `intent: str | None` — a machine-readable label (e.g. `"customer_support_lookup"`).
64
+ - `scope: dict[str, Any]` — a small structured map (e.g. `{"region": "eu-west", "customer_id": "C-42"}`).
65
+
66
+ `DeclarativePolicyEngine` rules can match on these via top-level keys in `match`:
67
+
68
+ ```yaml
69
+ - name: support_eu_lookup
70
+ match:
71
+ safety_class: [READ]
72
+ intent: [customer_support_lookup]
73
+ scope: { region: "eu-west" }
74
+ action: allow
75
+ ```
76
+
77
+ Intent-aware rules fail closed: a request with `intent=None` never matches a rule that requires a specific intent. `scope: { key: "*" }` means "the key must be present with any value".
58
78
 
59
79
  #### Denial explanations
60
80
 
61
- `PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`, a `remediation` list, and a human-readable `narrative`. Engines collect all failing conditions (no short-circuit) so callers get the full picture. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating.
81
+ `PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`/`reason_code`, a `remediation` list, a human-readable `narrative`, and a top-level `reason_code` (the code of the first failed condition). Engines collect all failing conditions (no short-circuit) so callers get the full picture. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating.
82
+
83
+ #### Reason codes
84
+
85
+ Every `PolicyDecision`, `DenialExplanation`, `FailedCondition`, and `PolicyDenied` from the built-in engines carries a stable `reason_code`. Assert on these codes — not on the human-readable `reason` / `narrative` strings:
86
+
87
+ | Code (`DenialReason.*`) | When |
88
+ |---|---|
89
+ | `missing_role` | Principal lacks a required role |
90
+ | `missing_tenant_attribute` | PII/PCI capability needs `tenant` attribute |
91
+ | `missing_attribute` | Declarative rule's required attribute absent or mismatched |
92
+ | `insufficient_justification` | Justification shorter than the minimum |
93
+ | `invalid_constraint` | Constraint value (e.g. `max_rows`) not parseable |
94
+ | `rate_limited` | Sliding-window rate limit exceeded |
95
+ | `no_matching_rule` | DSL: no rule matched + default `deny` |
96
+ | `explicit_deny_rule` | DSL: a `deny` rule matched fully |
97
+ | `intent_not_allowed` | DSL: `match.intent` rejected the request's intent |
98
+ | `scope_not_allowed` | DSL: `match.scope` rejected the request's scope |
99
+
100
+ Allow-side codes (`AllowReason.*`): `default_policy_allow`, `rule_allow`, `default_fallthrough_allow`, `token_verified`.
101
+
102
+ #### Decision trace
103
+
104
+ Every `PolicyDecision` from a built-in engine carries a `PolicyDecisionTrace` describing how the decision was reached: the engine name, the capability and principal IDs, the request's `intent` (echoed) and `scope_keys` (scope dimension names only — values are redacted), and an ordered list of `PolicyTraceStep` entries. Each step records the rule name, the outcome (`matched`/`skipped`/`denied`/`allowed`/`constraint_applied`), a human-readable detail, and — for terminal steps — the same stable `reason_code` carried on the decision. Traces are safe to log and serialize: they contain rule names, condition names, and codes only — never raw argument values.
62
105
 
63
106
  #### Dry-run mode
64
107
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "weaver-kernel"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Capability-based security kernel for AI agents operating in large tool ecosystems"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -16,6 +16,8 @@ Token management::
16
16
  Policy::
17
17
 
18
18
  from agent_kernel import DefaultPolicyEngine, DeclarativePolicyEngine
19
+ from agent_kernel import PolicyDecisionTrace, PolicyTraceStep
20
+ from agent_kernel import DenialReason, AllowReason
19
21
 
20
22
  Firewall::
21
23
 
@@ -82,6 +84,8 @@ from .models import (
82
84
  Handle,
83
85
  ImplementationRef,
84
86
  PolicyDecision,
87
+ PolicyDecisionTrace,
88
+ PolicyTraceStep,
85
89
  Principal,
86
90
  Provenance,
87
91
  RawResult,
@@ -91,6 +95,7 @@ from .models import (
91
95
  )
92
96
  from .policy import DefaultPolicyEngine, ExplainingPolicyEngine, PolicyEngine
93
97
  from .policy_dsl import DeclarativePolicyEngine, PolicyMatch, PolicyRule
98
+ from .policy_reasons import AllowReason, DenialReason
94
99
  from .registry import CapabilityRegistry
95
100
  from .router import StaticRouter
96
101
  from .tokens import CapabilityToken, HMACTokenProvider
@@ -117,6 +122,8 @@ __all__ = [
117
122
  "Handle",
118
123
  "ImplementationRef",
119
124
  "PolicyDecision",
125
+ "PolicyDecisionTrace",
126
+ "PolicyTraceStep",
120
127
  "Principal",
121
128
  "Provenance",
122
129
  "RawResult",
@@ -145,8 +152,10 @@ __all__ = [
145
152
  "TokenRevoked",
146
153
  "TokenScopeError",
147
154
  # policy
155
+ "AllowReason",
148
156
  "DefaultPolicyEngine",
149
157
  "DeclarativePolicyEngine",
158
+ "DenialReason",
150
159
  "ExplainingPolicyEngine",
151
160
  "PolicyEngine",
152
161
  "PolicyMatch",
@@ -28,7 +28,24 @@ class TokenRevoked(AgentKernelError):
28
28
 
29
29
 
30
30
  class PolicyDenied(AgentKernelError):
31
- """Raised when the policy engine rejects a capability request."""
31
+ """Raised when the policy engine rejects a capability request.
32
+
33
+ Carries an optional ``reason_code`` attribute holding a stable
34
+ :class:`~agent_kernel.policy_reasons.DenialReason` value so callers can
35
+ branch on it without matching the human-readable message:
36
+
37
+ .. code-block:: python
38
+
39
+ try:
40
+ kernel.grant_capability(request, principal, justification="...")
41
+ except PolicyDenied as exc:
42
+ if exc.reason_code == DenialReason.MISSING_ROLE:
43
+ ...
44
+ """
45
+
46
+ def __init__(self, message: str, *, reason_code: str | None = None) -> None:
47
+ super().__init__(message)
48
+ self.reason_code: str | None = reason_code
32
49
 
33
50
 
34
51
  class PolicyConfigError(AgentKernelError):
@@ -23,11 +23,14 @@ from .models import (
23
23
  Frame,
24
24
  Handle,
25
25
  PolicyDecision,
26
+ PolicyDecisionTrace,
27
+ PolicyTraceStep,
26
28
  Principal,
27
29
  ResponseMode,
28
30
  RoutePlan,
29
31
  )
30
32
  from .policy import DefaultPolicyEngine, PolicyEngine
33
+ from .policy_reasons import AllowReason
31
34
  from .registry import CapabilityRegistry
32
35
  from .router import Router, StaticRouter
33
36
  from .tokens import CapabilityToken, HMACTokenProvider, TokenProvider
@@ -303,6 +306,23 @@ class Kernel:
303
306
  SafetyClass.WRITE: "medium",
304
307
  SafetyClass.DESTRUCTIVE: "high",
305
308
  }
309
+ dry_run_trace = PolicyDecisionTrace(
310
+ engine="Kernel.invoke[dry_run]",
311
+ capability_id=token.capability_id,
312
+ principal_id=principal.principal_id,
313
+ intent=None,
314
+ scope_keys=[],
315
+ steps=[
316
+ PolicyTraceStep(
317
+ name="token_verified",
318
+ outcome="allowed",
319
+ detail="Token verified; original policy decision was at grant time.",
320
+ reason_code=str(AllowReason.TOKEN_VERIFIED),
321
+ )
322
+ ],
323
+ final_outcome="allowed",
324
+ final_reason_code=str(AllowReason.TOKEN_VERIFIED),
325
+ )
306
326
  return DryRunResult(
307
327
  capability_id=token.capability_id,
308
328
  principal_id=principal.principal_id,
@@ -310,6 +330,8 @@ class Kernel:
310
330
  allowed=True,
311
331
  reason="Token verified. Policy was evaluated at grant time.",
312
332
  constraints=dict(token.constraints),
333
+ reason_code=str(AllowReason.TOKEN_VERIFIED),
334
+ trace=dry_run_trace,
313
335
  ),
314
336
  driver_id=driver_id,
315
337
  operation=operation,
@@ -134,6 +134,22 @@ class CapabilityRequest:
134
134
  constraints: dict[str, Any] = field(default_factory=dict)
135
135
  """Optional execution constraints (e.g. ``{"max_rows": 10}``)."""
136
136
 
137
+ intent: str | None = None
138
+ """Structured intent label (e.g. ``"customer_support_lookup"``).
139
+
140
+ Free-text :attr:`goal` is still required for human-readable audit; ``intent``
141
+ is the machine-readable counterpart that declarative policies can match
142
+ on directly without parsing the goal. See :class:`PolicyMatch.intent`.
143
+ """
144
+
145
+ scope: dict[str, Any] = field(default_factory=dict)
146
+ """Structured scope metadata describing what the request narrows to.
147
+
148
+ Examples: ``{"region": "eu-west"}``, ``{"customer_id": "C-42"}``. Policies
149
+ can deny a capability invocation that is technically allowed but unsafe
150
+ for a particular scope. See :class:`PolicyMatch.scope`.
151
+ """
152
+
137
153
 
138
154
  @dataclass(slots=True)
139
155
  class Principal:
@@ -149,6 +165,77 @@ class Principal:
149
165
  """Arbitrary attributes, e.g. ``{"tenant": "acme"}``."""
150
166
 
151
167
 
168
+ @dataclass(slots=True)
169
+ class PolicyTraceStep:
170
+ """A single step recorded while a policy engine evaluated a request.
171
+
172
+ Steps describe what the engine considered, in order — which rule it
173
+ examined, whether it matched, what condition (if any) failed, and what
174
+ constraint (if any) was applied. Steps never contain raw argument values
175
+ from the caller; they reference fields and IDs only.
176
+ """
177
+
178
+ name: str
179
+ """Short label for the step (e.g. ``"safety_class:WRITE"`` or rule name)."""
180
+
181
+ outcome: Literal["matched", "skipped", "denied", "allowed", "constraint_applied"]
182
+ """What happened at this step.
183
+
184
+ - ``"matched"``: a rule's match clause matched and evaluation continues.
185
+ - ``"skipped"``: the step did not apply (e.g. wildcard, wrong safety class).
186
+ - ``"denied"``: this step produced the final denial.
187
+ - ``"allowed"``: this step produced the final allow.
188
+ - ``"constraint_applied"``: the step merged a constraint into the decision.
189
+ """
190
+
191
+ detail: str = ""
192
+ """Human-readable detail, e.g. ``"role 'writer' required, principal had ['reader']"``."""
193
+
194
+ reason_code: str | None = None
195
+ """For ``"denied"`` steps, the :class:`~agent_kernel.policy_reasons.DenialReason`.
196
+ For ``"allowed"`` steps, the :class:`~agent_kernel.policy_reasons.AllowReason`.
197
+ ``None`` for ``"matched"``, ``"skipped"``, and ``"constraint_applied"`` steps.
198
+ """
199
+
200
+
201
+ @dataclass(slots=True)
202
+ class PolicyDecisionTrace:
203
+ """Structured trace of how a :class:`PolicyDecision` was reached.
204
+
205
+ The trace lists every step the policy engine took, in order, so callers
206
+ can audit which rule matched, which conditions failed, and which
207
+ constraints were applied. The trace must not contain raw argument
208
+ values — only field names, role names, attribute names, rule names, and
209
+ safe IDs — so it is safe to serialize and log.
210
+ """
211
+
212
+ engine: str
213
+ """Engine identifier (e.g. ``"DefaultPolicyEngine"``)."""
214
+
215
+ capability_id: str
216
+ """The capability that was being evaluated."""
217
+
218
+ principal_id: str
219
+ """The principal the decision was made for."""
220
+
221
+ intent: str | None
222
+ """Echoed :attr:`CapabilityRequest.intent` (may be ``None``)."""
223
+
224
+ scope_keys: list[str] = field(default_factory=list)
225
+ """Scope dimension names present on the request (values redacted for safety)."""
226
+
227
+ steps: list[PolicyTraceStep] = field(default_factory=list)
228
+ """Ordered list of evaluation steps."""
229
+
230
+ final_outcome: Literal["allowed", "denied"] = "denied"
231
+ """The decision the engine reached."""
232
+
233
+ final_reason_code: str | None = None
234
+ """The :class:`~agent_kernel.policy_reasons.AllowReason` or
235
+ :class:`~agent_kernel.policy_reasons.DenialReason` for the final outcome.
236
+ """
237
+
238
+
152
239
  @dataclass(slots=True)
153
240
  class PolicyDecision:
154
241
  """Result of a policy engine evaluation."""
@@ -157,11 +244,27 @@ class PolicyDecision:
157
244
  """``True`` if the request is permitted."""
158
245
 
159
246
  reason: str
160
- """Human-readable explanation."""
247
+ """Human-readable explanation. Wording may evolve; assert on
248
+ :attr:`reason_code` for stable behavior."""
161
249
 
162
250
  constraints: dict[str, Any] = field(default_factory=dict)
163
251
  """Any additional constraints imposed by the policy (e.g. ``max_rows``)."""
164
252
 
253
+ reason_code: str | None = None
254
+ """Stable machine-readable code (typically a :class:`~agent_kernel.policy_reasons.AllowReason`
255
+ or :class:`~agent_kernel.policy_reasons.DenialReason` value).
256
+
257
+ Use this for assertions, metrics, and UI mapping. ``None`` only when an
258
+ out-of-tree policy engine has not populated it.
259
+ """
260
+
261
+ trace: PolicyDecisionTrace | None = None
262
+ """Structured trace of how this decision was reached.
263
+
264
+ Populated by both built-in engines on allow and deny paths. ``None`` for
265
+ third-party engines that don't produce a trace.
266
+ """
267
+
165
268
 
166
269
  @dataclass(slots=True)
167
270
  class CapabilityGrant:
@@ -306,6 +409,12 @@ class FailedCondition:
306
409
  suggestion: str
307
410
  """Actionable remediation hint."""
308
411
 
412
+ reason_code: str | None = None
413
+ """Stable machine-readable code (a :class:`~agent_kernel.policy_reasons.DenialReason` value).
414
+ Use this for assertions instead of matching the human-readable
415
+ :attr:`suggestion` string.
416
+ """
417
+
309
418
 
310
419
  @dataclass(slots=True)
311
420
  class DenialExplanation:
@@ -326,6 +435,12 @@ class DenialExplanation:
326
435
  narrative: str
327
436
  """Human-readable single-sentence summary."""
328
437
 
438
+ reason_code: str | None = None
439
+ """Primary :class:`~agent_kernel.policy_reasons.DenialReason` for the denial
440
+ (typically the code of the first :class:`FailedCondition`). ``None`` on the
441
+ allow path (``denied=False``).
442
+ """
443
+
329
444
 
330
445
  # ── Dry-run ───────────────────────────────────────────────────────────────────
331
446