Skip to content

Commit ff89600

Browse files
committed
fix(governance)!: complete outstanding v3.0.0 breaking changes
BREAKING CHANGE: Remove AGWEnvelope/AGWEnvelopeBuilder deprecated aliases, sign_archytan_digest() method, and create_ftra_node() deprecated kwargs. - Delete src/gateway/governance/agw_envelope.py module - Delete tests/test_agw_envelope.py - Remove sign_archytan_digest() from KMSSigner (use sign() instead) - Remove registry_path/plan_key kwargs from create_ftra_node() - Fix KMS_BATCH_ENABLED documentation (default is false) - Update CHANGELOG.md and docs/BREAKING_CHANGES_v3.md
1 parent ff0c530 commit ff89600

16 files changed

Lines changed: 475 additions & 225 deletions

CHANGELOG.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,32 +41,62 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4141

4242
## [3.0.0] - 2026-08-15
4343

44+
> **Corrected release:** v3.0.0 was initially tagged 2026-08-15 with four
45+
> breaking changes still outstanding. Those changes were completed
46+
> post-tag on branch `fix/v3-breaking-changes-completion` and are now
47+
> reflected below: the `AGWEnvelope`/`AGWEnvelopeBuilder` removal, the
48+
> `sign_archytan_digest()` removal, the `create_ftra_node()` deprecated-kwargs
49+
> removal (kwargs fully removed, not merely deprecated), and the
50+
> `KMS_BATCH_ENABLED` default-value discrepancy (resolved as `"false"`). See
51+
> [`docs/BREAKING_CHANGES_v3.md`](docs/BREAKING_CHANGES_v3.md) for full detail.
52+
4453
### Breaking Changes
4554
- Removed `stpa_validator.py` shim module — use `GeneratedSTPAValidator` directly
4655
- Removed `safety.py` re-export shim — import from `text_filter` and `cbf` directly
4756
- Removed `GovernanceClient`, `RedisClient`, `HybridClient` aliases
4857
- Removed `check_safety_constraints` legacy tool alias — use `simulate_governance_check`
49-
- Removed `create_ftra_node()` deprecated params (`registry_path`, `plan_key`)
58+
- Removed `create_ftra_node()` deprecated params (`registry_path`, `plan_key`) — kwargs no longer accepted; pass a `FtraNodeConfig` instance instead
5059
- Removed `CONTROL_META`, `EVIDENCE_SLA_SECONDS`, `ISO_CONTROL_MAP` aliases — use region-aware accessors
5160
- Removed `config/settings.py` module-level aliases — use `Config.X` class attributes
5261
- Migrated threshold env vars to `config/governance_thresholds.json` (env vars still work as overrides)
5362
- (CR-1) Removed Evidence Stream v1.0 schema support — v1.1 is now the only supported schema
5463
- (CR-2) Removed NeMo auto-apply path (`NEMO_AUTO_APPLY_ENABLED`) — all refinements require human approval
5564
- (CR-3) Renamed `update_state()``_update_state_unsafe()` — use `atomic_verify_and_commit()` instead
65+
- Removed `AGWEnvelope`/`AGWEnvelopeBuilder` backward-compatibility aliases (`src/gateway/governance/agw_envelope.py`, entire file deleted) — use `GovernanceEnvelope`/`GovernanceEnvelopeBuilder` from `src/gateway/governance/governance_envelope.py`
66+
- Removed `sign_archytan_digest()` method from `KMSSigner` (`src/gateway/governance/kms_signer.py`) — use `sign()` instead
5667

5768
### Added
5869
- `config/governance_thresholds.json` v2.0.0 schema with FRIA, confidence, and causal thresholds
5970
- Threshold accessor functions in `src/gateway/governance/schemas/thresholds.py`
6071
- Region-aware control metadata accessors (`get_control_meta()`, `get_sla_seconds()`, `get_iso_control_map()`)
6172

73+
### Removed
74+
- `src/gateway/governance/agw_envelope.py` (entire file) — `AGWEnvelope` and `AGWEnvelopeBuilder` backward-compatibility aliases; use `GovernanceEnvelope`/`GovernanceEnvelopeBuilder` from `src/gateway/governance/governance_envelope.py`
75+
- `tests/test_agw_envelope.py` — backward-compatibility test suite for the removed `AGWEnvelope`/`AGWEnvelopeBuilder` aliases; see `tests/test_governance_envelope.py` for canonical coverage
76+
- `sign_archytan_digest()` method from `KMSSigner` (`src/gateway/governance/kms_signer.py`) — use `sign()` instead
77+
- `create_ftra_node()` deprecated `registry_path`/`plan_key` keyword arguments (`src/gateway/governance/ftra/node_factory.py`) — fully removed, not just deprecated; pass a `FtraNodeConfig` instance instead
78+
6279
### Changed
6380
- `FtraNodeConfig` is now required for `create_ftra_node()` (no fallback extractors)
6481
- Threshold values loaded from config file with env var overrides
6582
- `SafetyBoundaryProtocol` no longer exposes `update_state()` method
6683

84+
### Fixed
85+
- `KMS_BATCH_ENABLED` default-value discrepancy (Wave 0) resolved: confirmed default is `"false"` (disabled), matching `KmsBatchThresholds.enabled` in `src/gateway/governance/schemas/thresholds.py` and `config/governance_thresholds.json`. **Note:** the startup log comment in `src/compliance_bridge/main.py` (near line 213) still states the default is `"true"` and requires a follow-up code fix to align with the verified `"false"` default.
86+
6787
### Migration
6888
See [MIGRATION_GUIDE_v3.md](docs/MIGRATION_GUIDE_v3.md) for detailed upgrade instructions.
6989

90+
Example migration for the `AGWEnvelope` removal:
91+
92+
```python
93+
# Old (removed in v3.0.0):
94+
from src.gateway.governance.agw_envelope import AGWEnvelope
95+
96+
# New (required):
97+
from src.gateway.governance.governance_envelope import GovernanceEnvelope
98+
```
99+
70100
---
71101

72102
## [2.1.2] - 2026-08-13

docs/BREAKING_CHANGES_v3.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
# CAGE v3.0.0 Breaking Changes
22

3-
> **Status:** Released — v3.0.0 shipped 2026-08-15. See
3+
> **Status:** Corrected release. v3.0.0 was initially tagged 2026-08-15 with
4+
> four breaking changes still outstanding (see below); those changes were
5+
> completed on branch `fix/v3-breaking-changes-completion` and this document
6+
> has been updated to reflect what was actually removed. See
47
> [`CHANGELOG.md`](../CHANGELOG.md) for the full release notes. This document
58
> describes the breaking changes included in this release. Item IDs (`SR-#`,
69
> `MR-#`, `CR-#`, `FF-#`, `EV-#`) match
710
> [`docs/MAJOR_VERSION_CLEANUP_PLAN.md`](MAJOR_VERSION_CLEANUP_PLAN.md) 1:1
811
> so the two documents can be cross-referenced.
12+
>
13+
> **Post-tag corrections (this update):** `AGWEnvelope`/`AGWEnvelopeBuilder`
14+
> removal was missing from this document entirely; `sign_archytan_digest()`
15+
> removal was missing from the Removed Classes/Functions table; the
16+
> `KMS_BATCH_ENABLED` discrepancy flagged in the original release notes is
17+
> now resolved (default confirmed as `"false"`, not `"true"` — see
18+
> [Feature Flags Graduated](#feature-flags-graduated)).
919
1020
## Overview
1121

@@ -49,6 +59,7 @@ behavior change in `v3.0.0`.
4959
|--------|-------------|-----------|
5060
| [`src/gateway/governance/stpa_validator.py`](../src/gateway/governance/stpa_validator.py) (`STPAValidator` class) | [`src/gateway/governance/generated_stpa_validator.py`](../src/gateway/governance/generated_stpa_validator.py:38) (`GeneratedSTPAValidator`) | Replace `from src.gateway.governance.stpa_validator import STPAValidator` with `from src.gateway.governance.generated_stpa_validator import GeneratedSTPAValidator`; replace `.validate(action_name, params)` calls with `.validate_generated(action_name, params)`. |
5161
| [`src/gateway/governance/safety.py`](../src/gateway/governance/safety.py) (entire file) | [`src/gateway/governance/text_filter.py`](../src/gateway/governance/text_filter.py) (`ac_keyword_scan`); [`src/gateway/governance/cbf.py`](../src/gateway/governance/cbf.py) (`ControlBarrierFunction`, `safety_filter`) | Replace `from src.gateway.governance.safety import ac_keyword_scan` with `from src.gateway.governance.text_filter import ac_keyword_scan`; replace `from src.gateway.governance.safety import ControlBarrierFunction, safety_filter` with `from src.gateway.governance.cbf import ControlBarrierFunction, safety_filter`. |
62+
| `src/gateway/governance/agw_envelope.py` (entire file — `AGWEnvelope`, `AGWEnvelopeBuilder` backward-compatibility aliases) | [`src/gateway/governance/governance_envelope.py`](../src/gateway/governance/governance_envelope.py) (`GovernanceEnvelope`, `GovernanceEnvelopeBuilder`) | Replace `from src.gateway.governance.agw_envelope import AGWEnvelope` with `from src.gateway.governance.governance_envelope import GovernanceEnvelope`; replace `AGWEnvelopeBuilder` with `GovernanceEnvelopeBuilder` (same module). `tests/test_agw_envelope.py` (the backward-compatibility test suite for these aliases) is also deleted — see [`tests/test_governance_envelope.py`](../tests/test_governance_envelope.py) for the canonical coverage. **(Completed post-tag, `fix/v3-breaking-changes-completion`.)** |
5263

5364
### Removed Classes/Functions
5465

@@ -63,6 +74,7 @@ behavior change in `v3.0.0`.
6374
| `EVIDENCE_SLA_SECONDS` (module-level dict alias) | [`src/compliance_bridge/types.py:446`](../src/compliance_bridge/types.py:446) | `get_sla_seconds(region)` | Replace direct dict access with `get_sla_seconds(region)`. Same universal-only → region-merged behavior note as `CONTROL_META` applies. |
6475
| `ISO_CONTROL_MAP` (module-level dict alias — **two distinct symbols**) | [`src/compliance_bridge/types.py:512`](../src/compliance_bridge/types.py:512) **and** [`src/gateway/governance/ontology.py:197-234`](../src/gateway/governance/ontology.py:197) (`TradingKnowledgeGraph.ISO_CONTROL_MAP` class attribute) | `get_iso_control_map(region)` (types.py); `get_control_map(region)` (ontology.py) | These are **two unrelated symbols with the same name in two different modules** — migrate each independently. `src/compliance_bridge/types.py` callers use `get_iso_control_map(region)`; `TradingKnowledgeGraph` callers use `get_control_map(region)`. |
6576
| `update_state()` (public API) | [`src/gateway/governance/cbf.py:907-998`](../src/gateway/governance/cbf.py:907) | `atomic_verify_and_commit()` (same module) | **Completed (CR-3)**: `update_state()` was renamed to `_update_state_unsafe()` (internal-only) to eliminate TOCTOU race conditions. External callers must call `atomic_verify_and_commit()`, which performs the CBF safety check and state commit atomically within a single Redis Lua execution. |
77+
| `sign_archytan_digest()` (method) | [`src/gateway/governance/kms_signer.py`](../src/gateway/governance/kms_signer.py) (`KMSSigner` class) | `sign()` (same class) | Replace `kms_signer.sign_archytan_digest(digest)` with `kms_signer.sign(payload)`; `sign()` is the canonical signing entry point and covers the same code path. **(Completed post-tag, `fix/v3-breaking-changes-completion`.)** |
6678

6779
### Removed Endpoints
6880

@@ -118,7 +130,7 @@ corresponding module is migrated; use the config file instead.
118130
| Flag | New Behavior |
119131
|------|--------------|
120132
| `CAGE_DEFER_ENABLED` | **Not graduated in v3.0.0** (explicit recommendation in the cleanup plan §2.4). The flag remains, still defaulting to `"true"`. If your deployment currently sets this to `"false"` to force the DENY-fallback path, that behavior is **unchanged** in v3.0.0. This is a deliberate deviation from the "graduate stable flags" theme of this release — flagged here so consumers do not assume removal. |
121-
| `KMS_BATCH_ENABLED` | **Status uncertain pending Wave 0 discrepancy resolution.** [`kms_batch_signer.py:75`](../src/compliance_bridge/kms_batch_signer.py:75) currently defaults this to `"true"`; [`main.py:211-212`](../src/compliance_bridge/main.py:211)'s comment claims the production default is `"false"`. **Do not assume this flag is graduated to any particular value until the CAGE release notes for your specific `v3.0.0` build confirm the resolved default.** If graduated, the flag is hardcoded and the `KMS_BATCH_ENABLED` env var (see above) is removed. |
133+
| `KMS_BATCH_ENABLED` | **Resolved.** The Wave 0 discrepancy is closed: the confirmed default is `"false"` (disabled), matching [`KmsBatchThresholds.enabled`](../src/gateway/governance/schemas/thresholds.py:277) (`Field(default=False, ...)`) and [`config/governance_thresholds.json`](../config/governance_thresholds.json:56) (`"enabled": false`). The flag is **not graduated**`KMS_BATCH_ENABLED` remains a valid env-var override of the config default via `get_kms_batch_enabled()`. **Known documentation debt (not yet code-fixed):** the startup comment at [`main.py:213`](../src/compliance_bridge/main.py:213) still incorrectly states "The signer is enabled by default (kms_batch.enabled=true..." — this comment is stale and requires a follow-up code change (out of scope for this documentation-only correction) to align with the verified `false` default. |
122134

123135
---
124136

patch_kms.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import re
2+
3+
with open("src/gateway/governance/kms_signer.py", "r") as f:
4+
content = f.read()
5+
6+
# Add get_public_keys_pem to BaseKMSProvider
7+
base_provider_replace = """ def get_public_key_pem(self) -> bytes:
8+
\"\"\"Fetch the public key PEM from the cloud provider.\"\"\"
9+
pass
10+
11+
def get_public_keys_pem(self) -> dict[str, bytes]:
12+
\"\"\"Fetch all active public key PEMs. Defaults to returning the single primary key.\"\"\"
13+
return {"default": self.get_public_key_pem()}
14+
"""
15+
content = re.sub(
16+
r" def get_public_key_pem\(self\) -> bytes:\n \"\"\"Fetch the public key PEM from the cloud provider\.\"\"\"\n pass\n",
17+
base_provider_replace,
18+
content
19+
)
20+
21+
# Add get_public_keys_pem to GCPKMSProvider
22+
gcp_provider_add = """ def get_public_key_pem(self) -> bytes:
23+
response = self._kms_client.get_public_key(name=self._key_version_name) # type: ignore[union-attr]
24+
return response.pem.encode("utf-8")
25+
26+
def get_public_keys_pem(self) -> dict[str, bytes]:
27+
\"\"\"Fetch all ENABLED public keys for the CryptoKey to support rotation.\"\"\"
28+
parts = self._key_version_name.split("/cryptoKeyVersions/")
29+
if len(parts) != 2:
30+
return {self._key_version_name: self.get_public_key_pem()}
31+
32+
crypto_key_name = parts[0]
33+
keys = {}
34+
try:
35+
versions = self._kms_client.list_crypto_key_versions(parent=crypto_key_name) # type: ignore[union-attr]
36+
for v in versions:
37+
if v.state.name == "ENABLED":
38+
pub = self._kms_client.get_public_key(name=v.name) # type: ignore[union-attr]
39+
keys[v.name] = pub.pem.encode("utf-8")
40+
except Exception as exc:
41+
import logging
42+
logging.getLogger(__name__).warning("Failed to list crypto key versions: %s", exc)
43+
44+
if not keys:
45+
keys[self._key_version_name] = self.get_public_key_pem()
46+
return keys
47+
"""
48+
content = re.sub(
49+
r" def get_public_key_pem\(self\) -> bytes:\n response = self._kms_client.get_public_key\(name=self._key_version_name\) # type: ignore\[union-attr\]\n return response.pem.encode\(\"utf-8\"\)\n",
50+
gcp_provider_add,
51+
content
52+
)
53+
54+
# Add get_jwks to KMSGovernanceSigner
55+
signer_add = """ def get_public_key_pem(self) -> bytes:
56+
if not self._public_key_pem:
57+
raise RuntimeError("No public key is loaded.")
58+
return self._public_key_pem
59+
60+
def get_jwks(self) -> dict[str, dict]:
61+
\"\"\"Return a JSON Web Key Set (JWKS) dictionary of all enabled keys.\"\"\"
62+
from src.gateway.governance.jwks import pem_to_jwk
63+
64+
if not self._provider:
65+
if self._public_key_pem:
66+
jwk = pem_to_jwk(self._public_key_pem)
67+
return {"keys": [jwk]}
68+
return {"keys": []}
69+
70+
pems = self._provider.get_public_keys_pem()
71+
keys = []
72+
for pem in pems.values():
73+
try:
74+
keys.append(pem_to_jwk(pem))
75+
except Exception:
76+
pass
77+
return {"keys": keys}
78+
"""
79+
content = re.sub(
80+
r" def get_public_key_pem\(self\) -> bytes:\n if not self._public_key_pem:\n raise RuntimeError\(\"No public key is loaded.\"\)\n return self._public_key_pem\n",
81+
signer_add,
82+
content
83+
)
84+
85+
with open("src/gateway/governance/kms_signer.py", "w") as f:
86+
f.write(content)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
# pulled in transitively (e.g. via google-adk / langfuse).
3535
"aiohttp>=3.14.3",
3636
"pytest-cov>=7.1.0",
37+
"jcs>=0.2.1",
3738
]
3839

3940
requires-python = ">=3.10,<3.13"

src/compliance_bridge/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,8 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
211211
# ------------------------------------------------------------------
212212
# C-08: Start KMS batch signer for evidence chain signing.
213213
# The signer is disabled by default (kms_batch.enabled=false in
214-
# config/governance_thresholds.json) and must be explicitly enabled
215-
# in production via env var KMS_BATCH_ENABLED or config override.
214+
# config/governance_thresholds.json). Can be explicitly enabled
215+
# via env var KMS_BATCH_ENABLED=true or config override.
216216
# assert_kms_active_in_production() enforces KMS mode in prod.
217217
# ------------------------------------------------------------------
218218
from src.gateway.governance.schemas.thresholds import get_kms_batch_enabled

src/gateway/core/structs.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import re
2121
import uuid
2222

23-
from pydantic import BaseModel, Field, field_validator
23+
from pydantic import BaseModel, Field, field_validator, model_validator
2424

2525

2626
class TradeOrder(BaseModel):
@@ -32,7 +32,8 @@ class TradeOrder(BaseModel):
3232

3333
# User-provided fields
3434
symbol: str = Field(..., description="Ticker symbol of the asset")
35-
amount: float = Field(..., description="Amount to trade")
35+
amount: float | None = Field(default=None, description="Amount to trade")
36+
amount_minor: int | None = Field(default=None, description="Amount to trade in minor units (e.g. cents).")
3637
currency: str = Field(..., description="Currency code (e.g. USD, EUR)")
3738
confidence: float = Field(
3839
...,
@@ -72,12 +73,28 @@ def validate_confidence(cls, v): # type: ignore[no-untyped-def]
7273
@field_validator("amount")
7374
@classmethod
7475
def validate_positive(cls, v): # type: ignore[no-untyped-def]
76+
if v is None:
77+
return v
7578
if not math.isfinite(v):
7679
raise ValueError("Amount must be a finite number, got non-finite value")
7780
if v <= 0:
7881
raise ValueError("Amount must be positive")
7982
return v
8083

84+
@model_validator(mode="after")
85+
def validate_amounts(self): # type: ignore[no-untyped-def]
86+
if self.amount is None and self.amount_minor is None:
87+
raise ValueError("Must provide either amount or amount_minor")
88+
if self.amount is not None and self.amount_minor is None:
89+
self.amount_minor = int(round(self.amount * 100))
90+
elif self.amount_minor is not None and self.amount is None:
91+
self.amount = self.amount_minor / 100.0
92+
93+
if self.amount_minor is not None and self.amount_minor <= 0:
94+
raise ValueError("Amount must be positive")
95+
96+
return self
97+
8198
@field_validator("transaction_id")
8299
@classmethod
83100
def validate_uuid(cls, v): # type: ignore[no-untyped-def]

src/gateway/governance/cbf.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,10 +877,15 @@ def _resolve_trade_cost(action_name: str, payload: dict[str, Any]) -> float:
877877
"""
878878
if action_name != "execute_trade":
879879
return 0.0
880-
cost = float(payload.get("amount", 0.0))
880+
881+
if "amount_minor" in payload and payload["amount_minor"] is not None:
882+
cost = float(payload["amount_minor"]) / 100.0
883+
else:
884+
cost = float(payload.get("amount", 0.0))
885+
881886
if not math.isfinite(cost) or cost < 0:
882887
raise ValueError(
883-
f"invalid trade amount {payload.get('amount')!r} — "
888+
f"invalid trade amount {cost!r} — "
884889
"must be a finite, non-negative number"
885890
)
886891
return cost

src/gateway/governance/contracts.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ def __post_init__(self) -> None:
111111
}
112112
for tf in self.tier_failures
113113
]
114-
canon = json.dumps(payload, sort_keys=True, separators=(",", ":"))
114+
from src.gateway.governance.jcs_canonicalizer import jcs_canonicalize_plan
115+
canon = jcs_canonicalize_plan(payload)
115116
object.__setattr__(
116-
self, "proof_hash", hashlib.sha256(canon.encode()).hexdigest()
117+
self, "proof_hash", hashlib.sha256(canon).hexdigest()
117118
)
118119

119120

@@ -157,9 +158,10 @@ def __post_init__(self) -> None:
157158
"standing_at_pause": self.standing_at_pause,
158159
"timestamp": self.timestamp,
159160
}
160-
canon = json.dumps(payload, sort_keys=True, separators=(",", ":"))
161+
from src.gateway.governance.jcs_canonicalizer import jcs_canonicalize_plan
162+
canon = jcs_canonicalize_plan(payload)
161163
object.__setattr__(
162-
self, "proof_hash", hashlib.sha256(canon.encode()).hexdigest()
164+
self, "proof_hash", hashlib.sha256(canon).hexdigest()
163165
)
164166

165167

0 commit comments

Comments
 (0)