auth51-governance 0.1.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.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes on a GitHub Release. Uses PyPI Trusted Publishing (OIDC) — no stored
4
+ # API token. Configure the trusted publisher on PyPI first: project auth51-governance
5
+ # → publisher = this repo, workflow "publish.yml", environment "pypi".
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch: {}
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ pypi:
17
+ runs-on: ubuntu-latest
18
+ environment: pypi
19
+ permissions:
20
+ # Job-level permissions REPLACE the workflow-level block (not merged), so
21
+ # both must be listed here or checkout 404s on a private repo.
22
+ contents: read # let actions/checkout read the (private) repo
23
+ id-token: write # mint the OIDC token PyPI verifies (no stored secret)
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+ - run: python -m pip install --upgrade build
30
+ - run: python -m build
31
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: auth51-governance
3
+ Version: 0.1.0
4
+ Summary: The auth51 governance engine — classify action consequence into tiers, synthesize guarded workflows from observed runs, and validate a proposed workflow against the tier floor at registration.
5
+ Project-URL: Homepage, https://auth51.com
6
+ Project-URL: Repository, https://github.com/unforge-io/auth51-governance
7
+ Author-email: unforge <dev-admin@unforge.io>
8
+ License: Apache-2.0
9
+ Keywords: agentic-authorization,ai-agents,auth51,governance,workflow
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # auth51-governance
18
+
19
+ The governance-time engine for auth51. It does three things, all pure and stdlib-only:
20
+
21
+ 1. **Classify** an action's consequence into an ordered tier
22
+ (`READ < WRITE < SENSITIVE < FINANCIAL < DESTRUCTIVE`).
23
+ 2. **Synthesize** a guarded workflow from sampled run observations plus the roster's
24
+ capability catalog. Guard *strength* follows the tier, not a single boolean.
25
+ 3. **Validate** a proposed workflow against the tier floor, so the authority can reject
26
+ a workflow that frees a dangerous capability regardless of who proposed it.
27
+
28
+ ## Why it's a separate library
29
+
30
+ Deriving and registering workflows is a **governance-time** act, not a runtime one —
31
+ even though the learning signal (observations) comes from runs. Any client can produce
32
+ observations: the reference workforce, a custom app on the auth51 client, or the
33
+ auth51d gateway. They all feed the same engine here, and the authority stays the single
34
+ source of truth for guard strength by calling `validate_workflow` at registration.
35
+
36
+ Kept dependency-light and portable, exactly like `auth51-checksum`, so both the
37
+ authority and clients can depend on it without pulling a framework.
38
+
39
+ ## The guard ladder
40
+
41
+ | | observed (≥ `min_support` runs) | unobserved / under-supported |
42
+ |-------------|--------------------------------------------|------------------------------|
43
+ | READ | free | (no step) |
44
+ | WRITE | free | (no step) |
45
+ | SENSITIVE | ordered (must follow legit predecessors) | approval gate |
46
+ | FINANCIAL | ordered (value-bound in a later phase) | approval gate |
47
+ | DESTRUCTIVE | approval gate (never downgraded) | approval gate |
48
+
49
+ Two properties over the old binary model: a real ladder (an identity edit ≠ a money
50
+ movement), and high tiers never auto-free (a DESTRUCTIVE op seen once still needs a
51
+ human; a FINANCIAL op is never free).
52
+
53
+ ## Usage
54
+
55
+ ```python
56
+ from auth51_governance import (
57
+ Capability, obs_from_spans, synthesize, validate_workflow, ConsequenceTier,
58
+ )
59
+
60
+ # what the roster CAN do (classified from spec risk/tags, else the name)
61
+ caps = [
62
+ Capability.classify("refunder", "RetrieveCustomer"),
63
+ Capability.classify("refunder", "CreatePayout", risk="financial"),
64
+ Capability.classify("admin", "DropDatabase"),
65
+ ]
66
+
67
+ # what runs DID (from each run's spans)
68
+ runs = [obs_from_spans(spans) for spans in sampled_runs]
69
+
70
+ wf = synthesize("refund-flow", runs, caps) # a guarded WorkflowDefinition dict
71
+
72
+ # at the authority, before registering:
73
+ violations = validate_workflow(wf, tier_of=lambda a, act: my_registry_tier(a, act))
74
+ assert not violations
75
+ ```
76
+
77
+ ## Compiles down to the authority's step primitives
78
+
79
+ `synthesize` emits the same `WorkflowStep` shape the authority already enforces:
80
+ `dependencies` (ordering), `scopes` (subset), and an approval-gate step. Each step also
81
+ carries a `tier` annotation the authority stores and the value-binding phase will use.
@@ -0,0 +1,65 @@
1
+ # auth51-governance
2
+
3
+ The governance-time engine for auth51. It does three things, all pure and stdlib-only:
4
+
5
+ 1. **Classify** an action's consequence into an ordered tier
6
+ (`READ < WRITE < SENSITIVE < FINANCIAL < DESTRUCTIVE`).
7
+ 2. **Synthesize** a guarded workflow from sampled run observations plus the roster's
8
+ capability catalog. Guard *strength* follows the tier, not a single boolean.
9
+ 3. **Validate** a proposed workflow against the tier floor, so the authority can reject
10
+ a workflow that frees a dangerous capability regardless of who proposed it.
11
+
12
+ ## Why it's a separate library
13
+
14
+ Deriving and registering workflows is a **governance-time** act, not a runtime one —
15
+ even though the learning signal (observations) comes from runs. Any client can produce
16
+ observations: the reference workforce, a custom app on the auth51 client, or the
17
+ auth51d gateway. They all feed the same engine here, and the authority stays the single
18
+ source of truth for guard strength by calling `validate_workflow` at registration.
19
+
20
+ Kept dependency-light and portable, exactly like `auth51-checksum`, so both the
21
+ authority and clients can depend on it without pulling a framework.
22
+
23
+ ## The guard ladder
24
+
25
+ | | observed (≥ `min_support` runs) | unobserved / under-supported |
26
+ |-------------|--------------------------------------------|------------------------------|
27
+ | READ | free | (no step) |
28
+ | WRITE | free | (no step) |
29
+ | SENSITIVE | ordered (must follow legit predecessors) | approval gate |
30
+ | FINANCIAL | ordered (value-bound in a later phase) | approval gate |
31
+ | DESTRUCTIVE | approval gate (never downgraded) | approval gate |
32
+
33
+ Two properties over the old binary model: a real ladder (an identity edit ≠ a money
34
+ movement), and high tiers never auto-free (a DESTRUCTIVE op seen once still needs a
35
+ human; a FINANCIAL op is never free).
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from auth51_governance import (
41
+ Capability, obs_from_spans, synthesize, validate_workflow, ConsequenceTier,
42
+ )
43
+
44
+ # what the roster CAN do (classified from spec risk/tags, else the name)
45
+ caps = [
46
+ Capability.classify("refunder", "RetrieveCustomer"),
47
+ Capability.classify("refunder", "CreatePayout", risk="financial"),
48
+ Capability.classify("admin", "DropDatabase"),
49
+ ]
50
+
51
+ # what runs DID (from each run's spans)
52
+ runs = [obs_from_spans(spans) for spans in sampled_runs]
53
+
54
+ wf = synthesize("refund-flow", runs, caps) # a guarded WorkflowDefinition dict
55
+
56
+ # at the authority, before registering:
57
+ violations = validate_workflow(wf, tier_of=lambda a, act: my_registry_tier(a, act))
58
+ assert not violations
59
+ ```
60
+
61
+ ## Compiles down to the authority's step primitives
62
+
63
+ `synthesize` emits the same `WorkflowStep` shape the authority already enforces:
64
+ `dependencies` (ordering), `scopes` (subset), and an approval-gate step. Each step also
65
+ carries a `tier` annotation the authority stores and the value-binding phase will use.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "auth51-governance"
7
+ version = "0.1.0"
8
+ description = "The auth51 governance engine — classify action consequence into tiers, synthesize guarded workflows from observed runs, and validate a proposed workflow against the tier floor at registration."
9
+ authors = [{ name = "unforge", email = "dev-admin@unforge.io" }]
10
+ license = { text = "Apache-2.0" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ dependencies = [] # stdlib only — this must be trivially portable
14
+ keywords = ["auth51", "agentic-authorization", "workflow", "governance", "ai-agents"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Security",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://auth51.com"
24
+ Repository = "https://github.com/unforge-io/auth51-governance"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/auth51_governance"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["src"]
@@ -0,0 +1,55 @@
1
+ """
2
+ auth51-governance — the governance-time engine that turns observed runs into guarded
3
+ workflows, and validates a proposed workflow against the consequence-tier floor.
4
+
5
+ ONE engine, used on both sides of the trust boundary:
6
+ * a client (or CI, or the console) DERIVES a proposal from sampled observations;
7
+ * the authority VALIDATES the submitted workflow against its own policy before
8
+ registering it.
9
+
10
+ Tiers are OPEN (data — a configurable :class:`TierPolicy`); enforcement primitives are
11
+ CLOSED (:class:`GuardStrength`, what the authority can enforce). Every tier resolves to
12
+ a guard strength, so an enterprise can define its own tiers without ever meaning
13
+ something the enforcer can't honour.
14
+
15
+ Deriving/registering workflows is a governance-time act, not a runtime one — even
16
+ though the learning signal (observations) comes from runs. Keeping the engine here, as
17
+ a dependency-light shared library (like auth51-checksum), lets any client — the
18
+ reference workforce, a custom app on the auth51 client, or the auth51d gateway — derive
19
+ identically while the authority stays the single source of truth. Stdlib-only.
20
+ """
21
+ from .model import (
22
+ APPROVAL_ACTION,
23
+ APPROVAL_STEP,
24
+ APPROVER_AGENT,
25
+ Capability,
26
+ Key,
27
+ Obs,
28
+ )
29
+ from .observe import obs_from_spans
30
+ from .policy import (
31
+ DEFAULT_POLICY,
32
+ EvidenceState,
33
+ GuardStrength,
34
+ TierPolicy,
35
+ TierRule,
36
+ classify_consequence,
37
+ is_high_consequence,
38
+ strength_rank,
39
+ )
40
+ from .synthesize import synthesize
41
+ from .validate import TierLookup, step_strength, validate_workflow
42
+
43
+ __all__ = [
44
+ # policy model
45
+ "TierPolicy", "TierRule", "GuardStrength", "EvidenceState",
46
+ "DEFAULT_POLICY", "classify_consequence", "is_high_consequence", "strength_rank",
47
+ # value types
48
+ "Obs", "Capability", "Key",
49
+ "APPROVAL_STEP", "APPROVER_AGENT", "APPROVAL_ACTION",
50
+ # engine
51
+ "obs_from_spans", "synthesize",
52
+ "validate_workflow", "step_strength", "TierLookup",
53
+ ]
54
+
55
+ __version__ = "0.1.0"
@@ -0,0 +1,58 @@
1
+ """
2
+ Neutral value types shared across the governance engine.
3
+
4
+ ``Obs`` is what a run DID (one governed step). ``Capability`` is what the roster CAN
5
+ do (the full catalog, each tagged with its tier NAME under some policy). Both are
6
+ framework-agnostic: no OpenTelemetry, no LangChain, no HTTP. A client adapts its own
7
+ stores into these, and the engine consumes only these — that is what keeps derivation
8
+ client-agnostic.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Sequence, Tuple
14
+
15
+ from .policy import DEFAULT_POLICY, TierPolicy
16
+
17
+ Key = Tuple[str, str] # (agent, action)
18
+
19
+ # The synthetic human-approval step shared by every red line in a workflow.
20
+ APPROVAL_STEP = "approval"
21
+ APPROVER_AGENT = "human-approver"
22
+ APPROVAL_ACTION = "ApproveHighConsequence"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Obs:
27
+ """One governed step observed in a run: an agent performing an action, in order."""
28
+
29
+ agent: str
30
+ action: str
31
+ scopes: Tuple[str, ...] = ()
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Capability:
36
+ """An ``(agent, action)`` the roster CAN perform, with its consequence-tier NAME —
37
+ the full catalog, so a dangerous capability NEVER seen in a legit run is still
38
+ guarded (the red line is enumerated from what the agent COULD do, not only did).
39
+
40
+ ``tier`` is a name under the governing :class:`~auth51_governance.policy.TierPolicy`
41
+ (tiers are open, so this is a string, not a fixed enum)."""
42
+
43
+ agent: str
44
+ action: str
45
+ scopes: Tuple[str, ...] = ()
46
+ tier: str = "WRITE"
47
+
48
+ @classmethod
49
+ def classify(
50
+ cls, agent: str, action: str, scopes: Sequence[str] = (),
51
+ tags: Sequence[str] = (), risk: str = "",
52
+ *, policy: TierPolicy = DEFAULT_POLICY,
53
+ ) -> "Capability":
54
+ """Build a capability, classifying its tier from spec risk/tags then name under
55
+ ``policy``. The one place call sites should construct a capability from raw
56
+ surface data."""
57
+ return cls(agent, action, tuple(scopes),
58
+ policy.classify(action, tags, risk))
@@ -0,0 +1,33 @@
1
+ """
2
+ Trace → observations adapter.
3
+
4
+ The auth51 span format is a client contract: a governed tool call is a ``tool <name>``
5
+ span carrying ``auth51.tool`` (the action / operationId) and ``auth51.agent_id`` (the
6
+ acting agent). Any client that emits spans in that shape — the reference workforce, a
7
+ custom app on the auth51 client, or the auth51d gateway — produces observations the
8
+ engine can consume. This is the one place that knows the span shape; everything
9
+ downstream sees only :class:`Obs`.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import List, Sequence
14
+
15
+ from .model import Obs
16
+
17
+
18
+ def obs_from_spans(spans: Sequence[dict]) -> List[Obs]:
19
+ """Extract the ordered governed steps from ONE run's spans (already ordered by
20
+ start time). Mint/dpop/rs spans are skipped — they nest under the tool span."""
21
+ out: List[Obs] = []
22
+ for s in spans:
23
+ if not str(s.get("name") or "").startswith("tool "):
24
+ continue
25
+ attrs = s.get("attributes") or {}
26
+ action = attrs.get("auth51.tool")
27
+ agent = attrs.get("auth51.agent_id")
28
+ if action and agent:
29
+ scopes = attrs.get("auth51.scopes") or ()
30
+ if isinstance(scopes, str):
31
+ scopes = tuple(x for x in scopes.replace(",", " ").split() if x)
32
+ out.append(Obs(agent=str(agent), action=str(action), scopes=tuple(scopes)))
33
+ return out
@@ -0,0 +1,292 @@
1
+ """
2
+ Consequence-tier POLICY — the configurable risk model.
3
+
4
+ Tiers are open; enforcement primitives are closed. A tier is just data (a name, a
5
+ rank, classification rules, and a handler mapping evidence → guard strength). An
6
+ enterprise may define any tiers it wants, order them, and wire each to a guard. What it
7
+ cannot invent is a new ENFORCEMENT mechanism: every tier must resolve to a
8
+ :class:`GuardStrength` the Authority actually implements. That is what keeps derivation
9
+ and enforcement coherent — a tier can never mean something the enforcer doesn't
10
+ understand.
11
+
12
+ The policy is authority-owned config: :data:`DEFAULT_POLICY` ships, an enterprise
13
+ overrides per org/app, and it serializes (:meth:`TierPolicy.to_dict`) so a client
14
+ derives against the same policy it will be validated against. Stdlib-only.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+ from typing import Dict, List, Optional, Sequence, Tuple
22
+
23
+
24
+ class GuardStrength(str, Enum):
25
+ """The CLOSED set of enforcement primitives. A tier's handler may only choose from
26
+ these; the Authority has enforcement code for each. Ordered by restrictiveness."""
27
+
28
+ FREE = "free" # no guard — allowed
29
+ ORDERED = "ordered" # must follow its legit predecessors (+ scope subset)
30
+ VALUE_BOUND = "value_bound" # reserved: allowed only within derived value bounds (phase 3)
31
+ APPROVAL = "approval" # a human approval gate must complete first (a red line)
32
+ # An EXTERNAL primitive (delegate to a configured PDP) is the future escape hatch
33
+ # for enterprise-specific logic — a product capability, not free-form config.
34
+
35
+
36
+ _STRENGTH_RANK: Dict[GuardStrength, int] = {
37
+ GuardStrength.FREE: 0,
38
+ GuardStrength.ORDERED: 1,
39
+ GuardStrength.VALUE_BOUND: 2,
40
+ GuardStrength.APPROVAL: 3,
41
+ }
42
+
43
+
44
+ def strength_rank(g: GuardStrength) -> int:
45
+ return _STRENGTH_RANK[g]
46
+
47
+
48
+ class EvidenceState(str, Enum):
49
+ """How much clean evidence a capability has, which the handler branches on."""
50
+
51
+ UNOBSERVED = "unobserved" # never seen in a clean run
52
+ OBSERVED_UNTRUSTED = "observed_untrusted" # seen, but below min_support
53
+ OBSERVED_TRUSTED = "observed_trusted" # seen in >= min_support runs
54
+
55
+
56
+ @dataclass
57
+ class TierRule:
58
+ """One tier: a name, a rank (ordering; higher = more consequential), a handler
59
+ (``guards``: evidence → guard strength), an enforcement ``floor`` (the minimum the
60
+ Authority accepts for any step of this tier), and classification rules."""
61
+
62
+ name: str
63
+ rank: int
64
+ guards: Dict[EvidenceState, GuardStrength]
65
+ floor: GuardStrength = GuardStrength.FREE
66
+ risk_values: Tuple[str, ...] = () # spec risk strings that map to this tier
67
+ tags: Tuple[str, ...] = () # tags that classify to this tier
68
+ name_patterns: Tuple[str, ...] = () # regex strings matched on the action name
69
+
70
+ def guard(self, evidence: EvidenceState) -> GuardStrength:
71
+ return self.guards.get(evidence, self.floor)
72
+
73
+ def to_dict(self) -> dict:
74
+ return {
75
+ "name": self.name,
76
+ "rank": self.rank,
77
+ "guards": {k.value: v.value for k, v in self.guards.items()},
78
+ "floor": self.floor.value,
79
+ "risk_values": list(self.risk_values),
80
+ "tags": list(self.tags),
81
+ "name_patterns": list(self.name_patterns),
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, d: dict) -> "TierRule":
86
+ return cls(
87
+ name=str(d["name"]),
88
+ rank=int(d.get("rank", 0)),
89
+ guards={EvidenceState(k): GuardStrength(v)
90
+ for k, v in (d.get("guards") or {}).items()},
91
+ floor=GuardStrength(d.get("floor", "free")),
92
+ risk_values=tuple(d.get("risk_values") or ()),
93
+ tags=tuple(d.get("tags") or ()),
94
+ name_patterns=tuple(d.get("name_patterns") or ()),
95
+ )
96
+
97
+
98
+ _DEFAULT_READ_PATTERN = r"^(list|get|retrieve|describe|read|fetch|search|view|count|query|lookup)"
99
+
100
+
101
+ @dataclass
102
+ class TierPolicy:
103
+ """An ordered set of tiers plus the classification defaults. The source of truth for
104
+ guard strength; derivation and register-time validation both consume it."""
105
+
106
+ tiers: List[TierRule]
107
+ default_tier: str = "READ"
108
+ read_pattern: str = _DEFAULT_READ_PATTERN
109
+ min_support: int = 1
110
+
111
+ def __post_init__(self) -> None:
112
+ self._by_name: Dict[str, TierRule] = {t.name: t for t in self.tiers}
113
+ self._compiled: Dict[str, List[re.Pattern]] = {
114
+ t.name: [re.compile(p, re.IGNORECASE) for p in t.name_patterns]
115
+ for t in self.tiers
116
+ }
117
+ self._read_re = re.compile(self.read_pattern, re.IGNORECASE)
118
+
119
+ # ── lookups ──
120
+ def tier(self, name: str) -> Optional[TierRule]:
121
+ return self._by_name.get(name)
122
+
123
+ def rank_of(self, name: str) -> int:
124
+ t = self._by_name.get(name)
125
+ return t.rank if t else -1
126
+
127
+ def guard_for(self, name: str, evidence: EvidenceState) -> GuardStrength:
128
+ t = self._by_name.get(name)
129
+ return t.guard(evidence) if t else GuardStrength.FREE
130
+
131
+ def floor_for(self, name: str) -> GuardStrength:
132
+ t = self._by_name.get(name)
133
+ return t.floor if t else GuardStrength.FREE
134
+
135
+ # ── classification ──
136
+ def classify(self, action: str, tags: Sequence[str] = (), risk: str = "") -> str:
137
+ """Classify an action into a tier NAME. Precedence: an explicit spec ``risk``
138
+ value is authoritative; otherwise the highest-ranked tier whose tags match, or
139
+ (for a non-read name) whose name pattern matches. A read-verb name is classified
140
+ only by tags/risk, so ``ListPayouts`` stays READ unless a tag/risk raises it."""
141
+ rl = (risk or "").strip().lower()
142
+ if rl:
143
+ for t in self.tiers:
144
+ if rl in t.risk_values:
145
+ return t.name
146
+ tagset = {(x or "").strip().lower() for x in (tags or ())}
147
+ is_read = bool(self._read_re.search(action or ""))
148
+ best: Optional[TierRule] = None
149
+ for t in self.tiers:
150
+ matched = bool(tagset & set(t.tags))
151
+ if not matched and not is_read:
152
+ matched = any(p.search(action or "") for p in self._compiled[t.name])
153
+ if matched and (best is None or t.rank > best.rank):
154
+ best = t
155
+ return best.name if best else self.default_tier
156
+
157
+ def is_red_line_tier(self, name: str) -> bool:
158
+ """Whether this tier is ever a red line (its handler can require approval, or its
159
+ floor is above ORDERED) — used by the back-compat boolean shim."""
160
+ t = self._by_name.get(name)
161
+ if t is None:
162
+ return False
163
+ strengths = list(t.guards.values()) + [t.floor]
164
+ return any(strength_rank(g) >= strength_rank(GuardStrength.APPROVAL)
165
+ for g in strengths)
166
+
167
+ # ── serialization ──
168
+ def to_dict(self) -> dict:
169
+ return {
170
+ "default_tier": self.default_tier,
171
+ "read_pattern": self.read_pattern,
172
+ "min_support": self.min_support,
173
+ "tiers": [t.to_dict() for t in self.tiers],
174
+ }
175
+
176
+ @classmethod
177
+ def from_dict(cls, d: dict) -> "TierPolicy":
178
+ return cls(
179
+ tiers=[TierRule.from_dict(t) for t in (d.get("tiers") or ())],
180
+ default_tier=str(d.get("default_tier", "READ")),
181
+ read_pattern=str(d.get("read_pattern", _DEFAULT_READ_PATTERN)),
182
+ min_support=int(d.get("min_support", 1)),
183
+ )
184
+
185
+
186
+ # ── the default policy (ranks gapped by 10 so custom tiers can slot between) ──
187
+ _ALL_EVIDENCE = (
188
+ EvidenceState.UNOBSERVED,
189
+ EvidenceState.OBSERVED_UNTRUSTED,
190
+ EvidenceState.OBSERVED_TRUSTED,
191
+ )
192
+
193
+
194
+ def _all(g: GuardStrength) -> Dict[EvidenceState, GuardStrength]:
195
+ return {e: g for e in _ALL_EVIDENCE}
196
+
197
+
198
+ def _red_line_unless_trusted(trusted: GuardStrength) -> Dict[EvidenceState, GuardStrength]:
199
+ """Approval until there's enough clean evidence, then downgrade to ``trusted``."""
200
+ return {
201
+ EvidenceState.UNOBSERVED: GuardStrength.APPROVAL,
202
+ EvidenceState.OBSERVED_UNTRUSTED: GuardStrength.APPROVAL,
203
+ EvidenceState.OBSERVED_TRUSTED: trusted,
204
+ }
205
+
206
+
207
+ DEFAULT_POLICY = TierPolicy(
208
+ default_tier="READ",
209
+ tiers=[
210
+ TierRule(
211
+ name="READ", rank=0, guards=_all(GuardStrength.FREE),
212
+ floor=GuardStrength.FREE,
213
+ risk_values=("read", "none", "safe"),
214
+ tags=("read", "readonly", "none", "safe"),
215
+ ),
216
+ TierRule(
217
+ name="WRITE", rank=10, guards=_all(GuardStrength.FREE),
218
+ floor=GuardStrength.FREE,
219
+ risk_values=("write", "low", "medium"),
220
+ tags=("write", "mutation", "low", "medium"),
221
+ name_patterns=(r"(create|update|modify|edit|change|patch|replace|add|set|write|post|upsert)",),
222
+ ),
223
+ TierRule(
224
+ name="SENSITIVE", rank=20,
225
+ guards=_red_line_unless_trusted(GuardStrength.ORDERED),
226
+ floor=GuardStrength.FREE,
227
+ risk_values=("sensitive", "high"),
228
+ tags=("sensitive", "pii", "account", "identity", "high"),
229
+ name_patterns=(
230
+ r"(create|update|modify|edit|change|patch|replace|add|set)[a-z0-9_]*?"
231
+ r"(customer|account|user|member|owner|role|permission|policy|credential|"
232
+ r"secret|apikey|api_key|token|password|email|phone|address|profile)",
233
+ r"(customer|account|user|member|owner|role|permission|policy|credential|"
234
+ r"secret|token|email|phone|address|profile)[a-z0-9_]*?"
235
+ r"(create|update|modify|edit|change|patch|replace|add|set)",
236
+ ),
237
+ ),
238
+ TierRule(
239
+ name="FINANCIAL", rank=30,
240
+ guards=_red_line_unless_trusted(GuardStrength.ORDERED), # VALUE_BOUND in phase 3
241
+ floor=GuardStrength.ORDERED,
242
+ risk_values=("financial", "payment"),
243
+ tags=("financial", "payment", "payout", "money", "billing", "funds"),
244
+ name_patterns=(
245
+ r"(payout|transfer|refund|charge|payment|withdraw|deposit|settle|"
246
+ r"capture|disburse|remit|invoice|chargeback|topup|top_up)",
247
+ ),
248
+ ),
249
+ TierRule(
250
+ name="SECURITY", rank=40, guards=_all(GuardStrength.APPROVAL),
251
+ floor=GuardStrength.APPROVAL,
252
+ risk_values=("security",),
253
+ tags=("security", "iam", "privilege", "admin", "access"),
254
+ name_patterns=(
255
+ r"(attach|detach|put|set|create|update|delete)?[a-z0-9_]*polic",
256
+ r"grant[a-z0-9_]*(role|permission|access|scope)",
257
+ r"(revoke|escalat|assumerole|impersonat)",
258
+ r"(disable|enable)[a-z0-9_]*mfa",
259
+ r"(rotate|disable)[a-z0-9_]*(key|credential|secret)",
260
+ r"(add|remove)[a-z0-9_]*(admin|owner|member)",
261
+ ),
262
+ ),
263
+ TierRule(
264
+ name="DESTRUCTIVE", rank=50, guards=_all(GuardStrength.APPROVAL),
265
+ floor=GuardStrength.APPROVAL,
266
+ risk_values=("destructive", "critical"),
267
+ tags=("destructive", "delete"),
268
+ name_patterns=(
269
+ r"(drop|truncate|purge|destroy|wipe|erase|deprovision)",
270
+ r"delete[a-z0-9_]*(database|table|bucket|volume|cluster|instance|"
271
+ r"account|customer|user|record|data)",
272
+ r"terminate[a-z0-9_]*(instance|cluster|node)",
273
+ ),
274
+ ),
275
+ ],
276
+ )
277
+
278
+
279
+ def classify_consequence(
280
+ action: str, tags: Sequence[str] = (), risk: str = "",
281
+ *, policy: TierPolicy = DEFAULT_POLICY,
282
+ ) -> str:
283
+ """Classify one action into a tier name under ``policy`` (default: shipped policy)."""
284
+ return policy.classify(action, tags, risk)
285
+
286
+
287
+ def is_high_consequence(
288
+ action: str, tags: Sequence[str] = (), risk: str = "",
289
+ *, policy: TierPolicy = DEFAULT_POLICY,
290
+ ) -> bool:
291
+ """Back-compat boolean: is this action's tier ever a red line under ``policy``?"""
292
+ return policy.is_red_line_tier(policy.classify(action, tags, risk))
@@ -0,0 +1,190 @@
1
+ """
2
+ Workflow synthesis from observed runs + the roster capability catalog.
3
+
4
+ Concrete runs of a use case are INSTANCES; the workflow is the CLASS induced from
5
+ them. We mine the observed ``(agent, action)`` steps and the partial order that holds
6
+ across runs, then emit a GUARDED workflow whose guard STRENGTH is set by each
7
+ capability's tier under the governing :class:`~auth51_governance.policy.TierPolicy` —
8
+ not a single boolean, and not a fixed table.
9
+
10
+ The tier's handler maps evidence (unobserved / observed-untrusted / observed-trusted)
11
+ to a :class:`~auth51_governance.policy.GuardStrength`, which is then compiled to the
12
+ authority's step primitives (``dependencies`` ordering, ``scopes`` subset, and an
13
+ approval-gate step). Because guard strength comes from the policy, an enterprise can
14
+ retune how strongly any tier is guarded without touching this code.
15
+
16
+ Pure + store-agnostic. Enforce-first + guarded by default.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Dict, List, Sequence, Set, Tuple
21
+
22
+ from .model import (
23
+ APPROVAL_ACTION,
24
+ APPROVAL_STEP,
25
+ APPROVER_AGENT,
26
+ Capability,
27
+ Key,
28
+ Obs,
29
+ )
30
+ from .policy import DEFAULT_POLICY, EvidenceState, GuardStrength, TierPolicy
31
+
32
+
33
+ def _slug(s: str) -> str:
34
+ out = "".join(c if c.isalnum() else "_" for c in (s or "").strip().lower())
35
+ return out.strip("_") or "step"
36
+
37
+
38
+ def synthesize(
39
+ workflow_id: str,
40
+ runs: Sequence[Sequence[Obs]],
41
+ capabilities: Sequence[Capability] = (),
42
+ *,
43
+ policy: TierPolicy = DEFAULT_POLICY,
44
+ mode: str = "enforce",
45
+ control: str = "guarded",
46
+ min_support: int | None = None,
47
+ approver_agent: str = APPROVER_AGENT,
48
+ approval_action: str = APPROVAL_ACTION,
49
+ ) -> dict:
50
+ """Return a ``WorkflowDefinition`` dict (the shape the Authority's
51
+ ``register/workflow`` accepts), guarded per ``policy``. ``min_support`` overrides the
52
+ policy's default: it is the run count a capability must reach to count as
53
+ OBSERVED_TRUSTED (below it, OBSERVED_UNTRUSTED — usually still a red line)."""
54
+ support_floor = policy.min_support if min_support is None else min_support
55
+
56
+ # ── observed steps, per-run first-occurrence index, cross-run support ──
57
+ observed: Dict[Key, Obs] = {}
58
+ run_first: List[Dict[Key, int]] = []
59
+ support: Dict[Key, int] = {}
60
+ for run in runs:
61
+ first: Dict[Key, int] = {}
62
+ for i, o in enumerate(run):
63
+ k = (o.agent, o.action)
64
+ observed.setdefault(k, o)
65
+ first.setdefault(k, i)
66
+ for k in first:
67
+ support[k] = support.get(k, 0) + 1
68
+ run_first.append(first)
69
+
70
+ keys = list(observed)
71
+ n = len(runs)
72
+
73
+ # ── always-before: A precedes B in EVERY run where both occur (>= 1 such run) ──
74
+ def always_before(a: Key, b: Key) -> bool:
75
+ seen_together = False
76
+ for first in run_first:
77
+ if a in first and b in first:
78
+ seen_together = True
79
+ if not first[a] < first[b]:
80
+ return False
81
+ return seen_together
82
+
83
+ preds: Dict[Key, Set[Key]] = {
84
+ b: {a for a in keys if a != b and always_before(a, b)} for b in keys
85
+ }
86
+
87
+ tier_of: Dict[Key, str] = {(c.agent, c.action): c.tier for c in capabilities}
88
+ cap_scopes: Dict[Key, Tuple[str, ...]] = {
89
+ (c.agent, c.action): c.scopes for c in capabilities
90
+ }
91
+
92
+ # ── stable, unique step names ──
93
+ used: Set[str] = set()
94
+ name_of: Dict[Key, str] = {}
95
+
96
+ def _name(agent: str, action: str) -> str:
97
+ base = _slug(action)
98
+ name = base if base not in used else f"{base}__{_slug(agent)}"
99
+ while name in used:
100
+ name += "_x"
101
+ used.add(name)
102
+ return name
103
+
104
+ for agent, action in keys:
105
+ name_of[(agent, action)] = _name(agent, action)
106
+
107
+ def required(k: Key) -> bool:
108
+ return n > 0 and all(k in first for first in run_first)
109
+
110
+ def evidence_of(k: Key, *, observed_flag: bool) -> EvidenceState:
111
+ if not observed_flag:
112
+ return EvidenceState.UNOBSERVED
113
+ return (EvidenceState.OBSERVED_TRUSTED
114
+ if support.get(k, 0) >= support_floor
115
+ else EvidenceState.OBSERVED_UNTRUSTED)
116
+
117
+ steps: Dict[str, dict] = {}
118
+ need_approval = False
119
+
120
+ def _emit(k: Key, *, agent: str, action: str, tier: str,
121
+ strength: GuardStrength, scopes: Sequence[str], is_required: bool) -> None:
122
+ nonlocal need_approval
123
+ deps = sorted(name_of[p] for p in preds.get(k, ())) # legit predecessors
124
+ value_bound = False
125
+ if strength is GuardStrength.FREE:
126
+ dependencies: List[str] = []
127
+ elif strength is GuardStrength.ORDERED:
128
+ dependencies = deps
129
+ elif strength is GuardStrength.VALUE_BOUND:
130
+ # Reserved (phase 3): ordering today, plus a marker the value-binding phase
131
+ # and register-time validation recognise. Enforced as ORDERED until then.
132
+ dependencies = deps
133
+ value_bound = True
134
+ else: # APPROVAL — a human gate must complete first (plus any legit order)
135
+ dependencies = sorted(set(deps) | {APPROVAL_STEP})
136
+ need_approval = True
137
+ step = {
138
+ "agent": agent,
139
+ "action": action,
140
+ "scopes": list(scopes),
141
+ "dependencies": dependencies,
142
+ "required": is_required,
143
+ "approval_gate": False,
144
+ "requires_approval": False,
145
+ "tier": tier,
146
+ }
147
+ if value_bound:
148
+ step["value_bound"] = True
149
+ steps[name_of[k]] = step
150
+
151
+ # Observed steps = the full class. Guard strength follows the tier + evidence.
152
+ for k in keys:
153
+ agent, action = k
154
+ tier = tier_of.get(k, policy.default_tier)
155
+ strength = policy.guard_for(tier, evidence_of(k, observed_flag=True))
156
+ _emit(k, agent=agent, action=action, tier=tier, strength=strength,
157
+ scopes=(observed[k].scopes or cap_scopes.get(k, ())),
158
+ is_required=required(k))
159
+
160
+ # Unobserved capabilities: a red line when the tier guards them above FREE; low
161
+ # tiers stay FREE and un-emitted so the guard set stays sparse (govern the boundary).
162
+ for c in capabilities:
163
+ k = (c.agent, c.action)
164
+ if k in observed:
165
+ continue
166
+ strength = policy.guard_for(c.tier, EvidenceState.UNOBSERVED)
167
+ if strength is GuardStrength.FREE:
168
+ continue
169
+ name_of.setdefault(k, _name(c.agent, c.action))
170
+ _emit(k, agent=c.agent, action=c.action, tier=c.tier, strength=strength,
171
+ scopes=c.scopes, is_required=False)
172
+
173
+ if need_approval:
174
+ steps[APPROVAL_STEP] = {
175
+ "agent": approver_agent,
176
+ "action": approval_action,
177
+ "scopes": [],
178
+ "dependencies": [],
179
+ "required": False,
180
+ "approval_gate": True,
181
+ "requires_approval": False,
182
+ }
183
+
184
+ return {
185
+ "workflow_id": workflow_id,
186
+ "workflow_type": "dag",
187
+ "mode": mode,
188
+ "control": control,
189
+ "steps": steps,
190
+ }
@@ -0,0 +1,63 @@
1
+ """
2
+ Register-time workflow validation — the trust boundary.
3
+
4
+ Derivation is a PROPOSAL and can run anywhere: a client's local dry-run, a CI job, or
5
+ the console. Because a client is replaceable and untrusted, the Authority must NOT trust
6
+ a submitted workflow's guards. It re-checks, against its OWN policy and its stored tier
7
+ per capability, that each step's guard strength meets the tier's ``floor``. This is not
8
+ a re-derive (the Authority has no traces); it is a floor check.
9
+
10
+ A capability whose tier the Authority does not know is skipped (it can only enforce the
11
+ floor for capabilities it has classified). Same posture as mint: the client may PROPOSE
12
+ anything; the Authority decides what may register.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Callable, List, Optional
17
+
18
+ from .policy import (
19
+ DEFAULT_POLICY,
20
+ GuardStrength,
21
+ TierPolicy,
22
+ strength_rank,
23
+ )
24
+
25
+ # (agent, action) -> tier name, or None when the Authority hasn't classified it.
26
+ TierLookup = Callable[[str, str], Optional[str]]
27
+
28
+
29
+ def step_strength(step: dict, approval_steps: set) -> GuardStrength:
30
+ """The guard strength a stored step actually carries, read from its fields."""
31
+ deps = set(step.get("dependencies") or [])
32
+ if step.get("requires_approval") or (deps & approval_steps):
33
+ return GuardStrength.APPROVAL
34
+ if step.get("value_bound"):
35
+ return GuardStrength.VALUE_BOUND
36
+ if deps:
37
+ return GuardStrength.ORDERED
38
+ return GuardStrength.FREE
39
+
40
+
41
+ def validate_workflow(
42
+ workflow: dict, tier_of: TierLookup, *, policy: TierPolicy = DEFAULT_POLICY,
43
+ ) -> List[str]:
44
+ """Return a list of floor violations (empty = OK to register). ``tier_of`` maps an
45
+ ``(agent, action)`` to the Authority's stored tier name (or ``None`` if unknown)."""
46
+ steps = workflow.get("steps") or {}
47
+ approval_steps = {name for name, s in steps.items() if s.get("approval_gate")}
48
+ violations: List[str] = []
49
+
50
+ for name, s in steps.items():
51
+ if s.get("approval_gate"):
52
+ continue # the gate itself is not a governed capability
53
+ tier = tier_of(s.get("agent", ""), s.get("action", ""))
54
+ if tier is None:
55
+ continue
56
+ floor = policy.floor_for(tier)
57
+ actual = step_strength(s, approval_steps)
58
+ if strength_rank(actual) < strength_rank(floor):
59
+ violations.append(
60
+ f"step '{name}' runs {tier} action '{s.get('action')}' at guard "
61
+ f"'{actual.value}', below the required floor '{floor.value}'")
62
+
63
+ return violations
@@ -0,0 +1,100 @@
1
+ """The default policy: classification, precedence, the SECURITY tier, serialization,
2
+ and — the point of the redesign — a fully custom enterprise policy."""
3
+ from auth51_governance import (
4
+ DEFAULT_POLICY,
5
+ EvidenceState,
6
+ GuardStrength,
7
+ TierPolicy,
8
+ TierRule,
9
+ classify_consequence,
10
+ is_high_consequence,
11
+ )
12
+
13
+ c = classify_consequence
14
+
15
+
16
+ def test_reads_are_read_tier():
17
+ for name in ("ListPayouts", "GetCustomer", "RetrieveCharge", "SearchTransfers",
18
+ "describe_account", "query_ledger"):
19
+ assert c(name) == "READ", name
20
+
21
+
22
+ def test_sensitive_nouns_lift_writes_to_sensitive():
23
+ assert c("UpdateCustomer") == "SENSITIVE"
24
+ assert c("SetUserRole") == "SENSITIVE"
25
+ assert c("AddTag") == "WRITE"
26
+ assert DEFAULT_POLICY.rank_of("UpdateCustomer" and c("UpdateCustomer")) < \
27
+ DEFAULT_POLICY.rank_of(c("CreatePayout"))
28
+
29
+
30
+ def test_financial_and_destructive_and_security_tiers():
31
+ assert c("CreatePayout") == "FINANCIAL"
32
+ assert c("IssueRefund") == "FINANCIAL"
33
+ assert c("DropDatabase") == "DESTRUCTIVE"
34
+ assert c("DeleteCustomer") == "DESTRUCTIVE"
35
+ assert c("AttachRolePolicy") == "SECURITY"
36
+ assert c("RevokeAccess") == "SECURITY"
37
+ assert c("DisableMfa") == "SECURITY"
38
+
39
+
40
+ def test_explicit_spec_risk_overrides_name_even_for_a_read_name():
41
+ assert c("GetPayoutNow", risk="financial") == "FINANCIAL"
42
+ assert c("RetrieveThing", risk="destructive") == "DESTRUCTIVE"
43
+ assert c("CreatePayout", risk="low") == "WRITE" # spec can LOWER it too
44
+
45
+
46
+ def test_tags_raise_a_read_name():
47
+ assert c("ListThings") == "READ"
48
+ assert c("ListThings", tags=["financial"]) == "FINANCIAL"
49
+ assert c("GetThing", tags=["destructive"]) == "DESTRUCTIVE"
50
+
51
+
52
+ def test_highest_rank_tier_wins():
53
+ # both WRITE (create) and FINANCIAL (payout) patterns match; the higher rank wins
54
+ assert c("CreatePayout") == "FINANCIAL"
55
+ assert c("DoThing", tags=["sensitive", "financial"]) == "FINANCIAL"
56
+
57
+
58
+ def test_high_consequence_shim():
59
+ assert is_high_consequence("CreatePayout") is True
60
+ assert is_high_consequence("UpdateCustomer") is True
61
+ assert is_high_consequence("DropDatabase") is True
62
+ assert is_high_consequence("GetCustomer") is False
63
+ assert is_high_consequence("AddTag") is False
64
+
65
+
66
+ def test_policy_serialization_roundtrip():
67
+ d = DEFAULT_POLICY.to_dict()
68
+ back = TierPolicy.from_dict(d)
69
+ for name in ("GetCustomer", "UpdateCustomer", "CreatePayout", "DropDatabase",
70
+ "AttachRolePolicy"):
71
+ assert back.classify(name) == DEFAULT_POLICY.classify(name), name
72
+ assert back.floor_for("FINANCIAL") == GuardStrength.ORDERED
73
+ assert back.floor_for("DESTRUCTIVE") == GuardStrength.APPROVAL
74
+
75
+
76
+ def test_custom_enterprise_policy_defines_new_tiers():
77
+ """An enterprise can define entirely new tiers + handlers; the engine honours them
78
+ without any code change here — the flexibility the design promises."""
79
+ approval_all = {e: GuardStrength.APPROVAL for e in EvidenceState}
80
+ custom = TierPolicy(
81
+ default_tier="PUBLIC",
82
+ tiers=[
83
+ TierRule("PUBLIC", 0, {e: GuardStrength.FREE for e in EvidenceState},
84
+ floor=GuardStrength.FREE),
85
+ TierRule("INTERNAL", 10, {e: GuardStrength.FREE for e in EvidenceState},
86
+ floor=GuardStrength.FREE,
87
+ name_patterns=(r"(create|update|set)",)),
88
+ TierRule("PHI_EXPORT", 90, approval_all, floor=GuardStrength.APPROVAL,
89
+ tags=("phi", "health"),
90
+ name_patterns=(r"(disclose|export)[a-z0-9_]*(record|patient|phi)",)),
91
+ ],
92
+ )
93
+ assert custom.classify("ExportPatientRecord") == "PHI_EXPORT"
94
+ assert custom.classify("DisclosePhi", tags=["health"]) == "PHI_EXPORT"
95
+ assert custom.classify("GetLedger") == "PUBLIC" # falls to default
96
+ assert custom.classify("UpdateThing") == "INTERNAL"
97
+ # a new tier's floor is enforced exactly like a built-in one
98
+ assert custom.floor_for("PHI_EXPORT") == GuardStrength.APPROVAL
99
+ # and it roundtrips through serialization (so the authority can store it)
100
+ assert TierPolicy.from_dict(custom.to_dict()).classify("ExportPatientRecord") == "PHI_EXPORT"
@@ -0,0 +1,129 @@
1
+ """The guard ladder in synthesis: tier + evidence → guard strength (policy-driven)."""
2
+ from auth51_governance import (
3
+ APPROVAL_STEP,
4
+ Capability,
5
+ EvidenceState,
6
+ GuardStrength,
7
+ Obs,
8
+ TierPolicy,
9
+ TierRule,
10
+ synthesize,
11
+ )
12
+
13
+
14
+ def _cap(agent, action, tier, scopes=()):
15
+ return Capability(agent, action, tuple(scopes), tier)
16
+
17
+
18
+ def _deps(wf, action):
19
+ for s in wf["steps"].values():
20
+ if s["action"] == action:
21
+ return set(s["dependencies"])
22
+ raise AssertionError(f"no step for {action}")
23
+
24
+
25
+ def _step(wf, action):
26
+ for s in wf["steps"].values():
27
+ if s["action"] == action:
28
+ return s
29
+ raise AssertionError(action)
30
+
31
+
32
+ def _has_step(wf, action):
33
+ return any(s["action"] == action for s in wf["steps"].values())
34
+
35
+
36
+ def test_reproduces_stripe_refund_screenshot():
37
+ """The exact console case: 2 clean runs observed only RetrieveCustomer; the roster's
38
+ dangerous ops (never observed) become red lines behind approval."""
39
+ caps = [
40
+ _cap("refunder", "RetrieveCustomer", "READ"),
41
+ _cap("refunder", "UpdateCustomer", "SENSITIVE"),
42
+ _cap("refunder", "CreateExternalAccount", "FINANCIAL"),
43
+ _cap("refunder", "CreatePayout", "FINANCIAL"),
44
+ _cap("refunder", "CreateTransfer", "FINANCIAL"),
45
+ ]
46
+ runs = [[Obs("refunder", "RetrieveCustomer")],
47
+ [Obs("refunder", "RetrieveCustomer")]]
48
+ wf = synthesize("refund-flow", runs, caps)
49
+
50
+ assert _deps(wf, "RetrieveCustomer") == set() # observed read → free
51
+ for action in ("UpdateCustomer", "CreateExternalAccount", "CreatePayout", "CreateTransfer"):
52
+ assert APPROVAL_STEP in _deps(wf, action), action # unobserved red lines → approval
53
+ assert wf["steps"][APPROVAL_STEP]["approval_gate"] is True
54
+ assert wf["mode"] == "enforce" and wf["control"] == "guarded"
55
+
56
+
57
+ def test_free_tiers_when_unobserved_emit_no_step():
58
+ caps = [
59
+ _cap("a", "GetThing", "READ"),
60
+ _cap("a", "AddTag", "WRITE"),
61
+ _cap("a", "CreatePayout", "FINANCIAL"),
62
+ ]
63
+ wf = synthesize("w", [], caps)
64
+ assert not _has_step(wf, "GetThing")
65
+ assert not _has_step(wf, "AddTag")
66
+ assert _has_step(wf, "CreatePayout")
67
+
68
+
69
+ def test_observed_sensitive_is_ordered_not_approval():
70
+ caps = [_cap("a", "GetCase", "READ"), _cap("a", "UpdateCustomer", "SENSITIVE")]
71
+ runs = [[Obs("a", "GetCase"), Obs("a", "UpdateCustomer")]]
72
+ wf = synthesize("w", runs, caps)
73
+ deps = _deps(wf, "UpdateCustomer")
74
+ assert APPROVAL_STEP not in deps and "getcase" in deps
75
+
76
+
77
+ def test_observed_financial_is_ordered_never_free():
78
+ caps = [_cap("a", "GetCase", "READ"), _cap("a", "IssueRefund", "FINANCIAL")]
79
+ runs = [[Obs("a", "GetCase"), Obs("a", "IssueRefund")]]
80
+ wf = synthesize("w", runs, caps)
81
+ assert _deps(wf, "IssueRefund") == {"getcase"}
82
+ assert _step(wf, "IssueRefund")["tier"] == "FINANCIAL"
83
+
84
+
85
+ def test_destructive_is_approval_even_when_observed():
86
+ """#4 fix: a destructive op seen in clean runs still needs a human."""
87
+ caps = [_cap("a", "GetCase", "READ"), _cap("a", "DropDatabase", "DESTRUCTIVE")]
88
+ runs = [[Obs("a", "GetCase"), Obs("a", "DropDatabase")]] * 5
89
+ wf = synthesize("w", runs, caps)
90
+ assert APPROVAL_STEP in _deps(wf, "DropDatabase")
91
+
92
+
93
+ def test_min_support_keeps_under_evidenced_financial_a_red_line():
94
+ caps = [_cap("a", "IssueRefund", "FINANCIAL")]
95
+ runs = [[Obs("a", "IssueRefund")]]
96
+ assert APPROVAL_STEP not in _deps(synthesize("w", runs, caps, min_support=1), "IssueRefund")
97
+ assert APPROVAL_STEP in _deps(synthesize("w", runs, caps, min_support=2), "IssueRefund")
98
+
99
+
100
+ def test_step_shape_matches_authority_fields():
101
+ caps = [_cap("a", "CreatePayout", "FINANCIAL")]
102
+ wf = synthesize("w", [], caps)
103
+ step = _step(wf, "CreatePayout")
104
+ for key in ("agent", "action", "scopes", "dependencies", "required",
105
+ "approval_gate", "requires_approval", "tier"):
106
+ assert key in step, key
107
+
108
+
109
+ def test_custom_policy_controls_guard_strength():
110
+ """A custom policy that value-binds an observed financial op instead of just
111
+ ordering it — synthesis honours the policy's handler without code changes here."""
112
+ policy = TierPolicy(
113
+ default_tier="READ",
114
+ tiers=[
115
+ TierRule("READ", 0, {e: GuardStrength.FREE for e in EvidenceState}),
116
+ TierRule("MONEY", 30, {
117
+ EvidenceState.UNOBSERVED: GuardStrength.APPROVAL,
118
+ EvidenceState.OBSERVED_UNTRUSTED: GuardStrength.APPROVAL,
119
+ EvidenceState.OBSERVED_TRUSTED: GuardStrength.VALUE_BOUND,
120
+ }, floor=GuardStrength.VALUE_BOUND, tags=("money",)),
121
+ ],
122
+ )
123
+ caps = [Capability("a", "GetCase", (), "READ"),
124
+ Capability("a", "SendMoney", (), "MONEY")]
125
+ runs = [[Obs("a", "GetCase"), Obs("a", "SendMoney")]]
126
+ wf = synthesize("w", runs, caps, policy=policy)
127
+ step = _step(wf, "SendMoney")
128
+ assert step["value_bound"] is True # policy chose VALUE_BOUND
129
+ assert APPROVAL_STEP not in set(step["dependencies"])
@@ -0,0 +1,75 @@
1
+ """Register-time floor: the authority rejects a workflow whose step guard is below the
2
+ tier's floor, independent of who derived it."""
3
+ from auth51_governance import Capability, Obs, synthesize, validate_workflow
4
+
5
+ # The authority's stored tier per capability (names under its policy).
6
+ _TIERS = {
7
+ ("a", "GetThing"): "READ",
8
+ ("a", "UpdateCustomer"): "SENSITIVE",
9
+ ("a", "CreatePayout"): "FINANCIAL",
10
+ ("a", "DropDatabase"): "DESTRUCTIVE",
11
+ }
12
+
13
+
14
+ def _tier_of(agent, action):
15
+ return _TIERS.get((agent, action))
16
+
17
+
18
+ def test_synthesized_workflow_passes_its_own_floor():
19
+ caps = [Capability(a, act, (), t) for (a, act), t in _TIERS.items()]
20
+ wf = synthesize("w", [[Obs("a", "GetThing")]], caps)
21
+ assert validate_workflow(wf, _tier_of) == []
22
+
23
+
24
+ def test_free_destructive_is_rejected():
25
+ wf = {"steps": {"drop": {"agent": "a", "action": "DropDatabase",
26
+ "dependencies": [], "approval_gate": False,
27
+ "requires_approval": False}}}
28
+ v = validate_workflow(wf, _tier_of)
29
+ assert len(v) == 1 and "DESTRUCTIVE" in v[0]
30
+
31
+
32
+ def test_ordered_only_destructive_is_still_rejected():
33
+ wf = {"steps": {
34
+ "drop": {"agent": "a", "action": "DropDatabase", "dependencies": ["prep"]},
35
+ "prep": {"agent": "a", "action": "GetThing", "dependencies": []},
36
+ }}
37
+ assert validate_workflow(wf, _tier_of) # ORDERED < APPROVAL floor
38
+
39
+
40
+ def test_free_financial_rejected_but_ordered_passes():
41
+ free = {"steps": {"pay": {"agent": "a", "action": "CreatePayout",
42
+ "dependencies": []}}}
43
+ ordered = {"steps": {
44
+ "pay": {"agent": "a", "action": "CreatePayout", "dependencies": ["prep"]},
45
+ "prep": {"agent": "a", "action": "GetThing", "dependencies": []},
46
+ }}
47
+ assert validate_workflow(free, _tier_of)
48
+ assert validate_workflow(ordered, _tier_of) == []
49
+
50
+
51
+ def test_approval_gated_destructive_passes():
52
+ wf = {"steps": {
53
+ "drop": {"agent": "a", "action": "DropDatabase", "dependencies": ["approval"]},
54
+ "approval": {"agent": "human", "action": "Approve", "dependencies": [],
55
+ "approval_gate": True},
56
+ }}
57
+ assert validate_workflow(wf, _tier_of) == []
58
+
59
+
60
+ def test_requires_approval_flag_also_satisfies_the_gate():
61
+ wf = {"steps": {"drop": {"agent": "a", "action": "DropDatabase",
62
+ "dependencies": [], "requires_approval": True}}}
63
+ assert validate_workflow(wf, _tier_of) == []
64
+
65
+
66
+ def test_sensitive_free_is_allowed_by_default_floor():
67
+ # SENSITIVE's floor is FREE (recoverable); it's ordered by derivation, not mandated
68
+ wf = {"steps": {"upd": {"agent": "a", "action": "UpdateCustomer",
69
+ "dependencies": []}}}
70
+ assert validate_workflow(wf, _tier_of) == []
71
+
72
+
73
+ def test_unknown_capability_tier_is_skipped():
74
+ wf = {"steps": {"x": {"agent": "z", "action": "MysteryOp", "dependencies": []}}}
75
+ assert validate_workflow(wf, _tier_of) == []