Skip to content

Commit 9677452

Browse files
committed
Add explicit policy-only decision boundary
1 parent c2110c4 commit 9677452

1 file changed

Lines changed: 57 additions & 1 deletion

File tree

src/sourceos_syncd/policy.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
The real PolicyFabric service will eventually own policy evaluation. This module
44
provides a local, deterministic contract-compatible evaluator so State Integrity
55
Reports can already carry policy counts and explanation codes.
6+
7+
Boundary invariant: this module emits policy decisions only. It does not perform
8+
runtime effects, mutate agent grants, repair state, write ledgers, or replicate
9+
payloads. Downstream systems must consume explicit decision refs before taking
10+
any action.
611
"""
712

813
from __future__ import annotations
@@ -15,6 +20,7 @@
1520

1621
POLICY_DECISION_SCHEMA = "sourceos.policy-decision/v1alpha1"
1722
POLICY_ENGINE = "policy-fabric-local-stub"
23+
DECISION_SCOPE = "policy-only"
1824

1925
ACTIONS = {
2026
"index",
@@ -44,6 +50,31 @@ class PolicyRequest:
4450
context: dict[str, Any] = field(default_factory=dict)
4551

4652

53+
@dataclass(frozen=True)
54+
class DecisionBoundary:
55+
"""Hard boundary carried by local policy decisions."""
56+
57+
decision_scope: str = DECISION_SCOPE
58+
runtime_effect_performed: bool = False
59+
authority_mutation_performed: bool = False
60+
state_repair_performed: bool = False
61+
ledger_write_performed: bool = False
62+
downstream_refs: tuple[str, ...] = (
63+
"SourceOS-Linux/sourceos-spec#113",
64+
"SourceOS-Linux/sourceos-syncd#30",
65+
)
66+
67+
def as_dict(self) -> dict[str, Any]:
68+
return {
69+
"decision_scope": self.decision_scope,
70+
"runtime_effect_performed": self.runtime_effect_performed,
71+
"authority_mutation_performed": self.authority_mutation_performed,
72+
"state_repair_performed": self.state_repair_performed,
73+
"ledger_write_performed": self.ledger_write_performed,
74+
"downstream_refs": list(self.downstream_refs),
75+
}
76+
77+
4778
@dataclass(frozen=True)
4879
class PolicyDecision:
4980
decision_id: str
@@ -57,6 +88,7 @@ class PolicyDecision:
5788
engine: str = POLICY_ENGINE
5889
schema: str = POLICY_DECISION_SCHEMA
5990
generated_at: str = field(default_factory=utc_now)
91+
boundary: DecisionBoundary = field(default_factory=DecisionBoundary)
6092

6193
def as_dict(self) -> dict[str, Any]:
6294
return {
@@ -71,9 +103,28 @@ def as_dict(self) -> dict[str, Any]:
71103
"subject": self.subject,
72104
"object_id": self.object_id,
73105
"data_class": self.data_class,
106+
"decision_boundary": self.boundary.as_dict(),
74107
}
75108

76109

110+
def validate_decision_boundary(decision: dict[str, Any]) -> None:
111+
"""Reject collapsed policy→runtime/authority/state records."""
112+
113+
boundary = decision.get("decision_boundary")
114+
if not isinstance(boundary, dict):
115+
raise ValueError("policy decision missing decision_boundary")
116+
if boundary.get("decision_scope") != DECISION_SCOPE:
117+
raise ValueError("policy decision scope must be policy-only")
118+
for key in (
119+
"runtime_effect_performed",
120+
"authority_mutation_performed",
121+
"state_repair_performed",
122+
"ledger_write_performed",
123+
):
124+
if boundary.get(key) is not False:
125+
raise ValueError(f"policy decision must not perform {key}")
126+
127+
77128
def _decision_id(request: PolicyRequest, status: str, reason: str) -> str:
78129
payload = {
79130
"action": request.action,
@@ -131,13 +182,16 @@ def evaluate_report_policy(lanes: list[dict[str, Any]], subject: str = "sourceos
131182
lane_name = str(lane.get("name", "normal"))
132183
for action in ("index", "retain", "replicate", "agent_access"):
133184
decision = evaluate_policy(PolicyRequest(action=action, lane=lane_name, subject=subject))
134-
decisions.append(decision.as_dict())
185+
payload = decision.as_dict()
186+
validate_decision_boundary(payload)
187+
decisions.append(payload)
135188
return decisions
136189

137190

138191
def decision_counts(decisions: list[dict[str, Any]]) -> dict[str, int]:
139192
counts = {status: 0 for status in sorted(STATUSES)}
140193
for decision in decisions:
194+
validate_decision_boundary(decision)
141195
status = str(decision.get("status", "deferred"))
142196
if status not in counts:
143197
status = "deferred"
@@ -146,6 +200,8 @@ def decision_counts(decisions: list[dict[str, Any]]) -> dict[str, int]:
146200

147201

148202
def policy_summary(decisions: list[dict[str, Any]], sample_limit: int = 12) -> dict[str, Any]:
203+
for decision in decisions:
204+
validate_decision_boundary(decision)
149205
return {
150206
"engine": POLICY_ENGINE,
151207
"counts": decision_counts(decisions),

0 commit comments

Comments
 (0)