Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.14"]

steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

- name: Install project
run: python -m pip install .

- name: Run tests
run: python -m unittest discover -s tests -v
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- Aligned the executable gate policy with the framework's five documented release outcomes: Release, Release with conditions, Hold, Do not release, and Defer decision.
- Distinguished remediable missing hard-gate evidence (`hold`) from explicit terminal conditions (`do_not_release`).
- Added explicit decision context for critical failures, prohibited conditions, unacceptable residual risk, and owner-requested deferral.
- Reject malformed Boolean evidence, unknown evidence/context fields, and risk inputs outside the documented 1–5 range instead of silently producing a misleading assessment.

### Changed

- CLI `decision` values now use the framework vocabulary (`release`, `release_with_conditions`, `hold`, `do_not_release`, `defer`) instead of the former three-value go/no-go vocabulary. Existing profile input shape remains valid.

### Added

- Automated unit tests for decision semantics, precedence, CLI serialization, and risk-tier thresholds.
- GitHub Actions CI with read-only repository permissions.
- Basic Python project metadata for reproducible runtime expectations.

## [0.1.0] — Foundation release

### Added
Expand Down
29 changes: 19 additions & 10 deletions docs/gating-matrix.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Operational Gating Matrix

This document translates the framework into a concrete, risk-tiered release model that a team can use during go or no-go review.
This document translates the framework into a concrete, risk-tiered release model that a team can use during release review.

## Gate definitions

Expand All @@ -21,27 +21,36 @@ This document translates the framework into a concrete, risk-tiered release mode
|---|---|---|---|---|
| Low | G1, G2, G4 | Scope and limitations note, basic monitoring plan | Product, engineering | Limited rollout preferred, rollback available |
| Medium | G1, G2, G3, G4, G5, G8 | Test evidence for degraded behavior, escalation path, monitoring thresholds | Product, engineering, operations, quality or compliance as applicable | Phased rollout, enhanced review frequency, launch criteria explicitly documented |
| High | G1, G2, G3, G4, G5, G6, G7, G8 | Formal evidence package, validated rollback or disablement, explicit accountability map, incident playbook drill or equivalent proof | Product, engineering, operations, quality, safety, security, compliance or legal as applicable | Strict rollout control, explicit go or no-go meeting, rapid disablement path, mandatory post-release review |
| High | G1, G2, G3, G4, G5, G6, G7, G8 | Formal evidence package, validated rollback or disablement, explicit accountability map, incident playbook drill or equivalent proof | Product, engineering, operations, quality, safety, security, compliance or legal as applicable | Strict rollout control, explicit release decision meeting, rapid disablement path, mandatory post-release review |

## Suggested go or no-go logic
## Suggested release decision logic

The executable policy distinguishes missing hard-gate evidence from an explicit terminal release prohibition. This keeps the five decision outcomes aligned with the release decision record.

### Low risk
- Go if all required gates are satisfied and there is a rollback path.
- Conditional go only if the open items are minor and do not affect control, monitoring, or scope clarity.
- **Release** if all required gates are satisfied and there is a rollback path.
- **Release with conditions** only if open items are non-hard-gate items and the conditions are bounded, enforceable, owned, and monitored.
- **Hold** if a hard gate such as monitoring readiness is incomplete.

### Medium risk
- Go only if all required gates are satisfied.
- Conditional go only with named owner, deadline, and compensating control.
- No-go if degraded-mode, monitoring, or escalation design is incomplete.
- **Release** only if all required gates are satisfied.
- **Release with conditions** only when every hard gate passes and remaining required actions can be bounded by named owner, deadline, scope, monitoring, and stop conditions.
- **Hold** if degraded-mode, monitoring, or escalation readiness is incomplete.

### High risk
- Go only if all required gates are satisfied and accountable approvers explicitly sign off.
- No-go if any of the following are missing:
- **Release** only if all required gates are satisfied and accountable approvers explicitly sign off.
- **Hold** if any required hard gate is incomplete, including:
- safe degraded behavior,
- production monitoring ownership,
- incident disablement or rollback mechanism,
- named accountable owner for post-release incidents.

Across all tiers:
- **Do not release** when the decision context records a critical failure, prohibited condition, or unacceptable residual risk.
- **Defer decision** only when the decision owner intentionally postpones judgment until specified evidence or an external dependency becomes available.

A missing hard gate is therefore a **Hold** by default, not automatically **Do not release**. A terminal outcome should be supported by an explicit reason rather than inferred from missing evidence alone.

## Evidence package checklist

A practical evidence package can include:
Expand Down
32 changes: 32 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,38 @@ Example JSON output:
python governance_cli.py ../examples/ivi_voice_assistant_release_profile.json --format json
```

## CLI decision semantics

The CLI uses the same five outcomes as the release decision record:

| Framework outcome | JSON `decision` value |
|---|---|
| Release | `release` |
| Release with conditions | `release_with_conditions` |
| Hold | `hold` |
| Do not release | `do_not_release` |
| Defer decision | `defer` |

Gate evidence alone produces **Release**, **Release with conditions**, or **Hold**. A missing hard gate produces **Hold** because missing evidence or control readiness is remediable unless the decision owner records a terminal condition.

Use the optional `decision_context` object for facts that cannot be inferred safely from Boolean gate evidence:

```json
{
"decision_context": {
"critical_failure": false,
"prohibited_condition": false,
"unacceptable_residual_risk": false,
"defer_reason": null
}
}
```

- Set a terminal Boolean only when the evidence supports that conclusion. Any of these fields produces **Do not release**.
- Set `defer_reason` only when the decision owner intentionally postpones judgment for named evidence or a dependency.
- If both a terminal condition and `defer_reason` are present, **Do not release** takes precedence so a known terminal condition is not hidden by deferral.
- Existing profiles without `decision_context` remain valid.

## Suggested review package

A lightweight release package can contain:
Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "ai-release-governance"
version = "0.1.0"
description = "Operational tooling for an AI release governance framework."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]

[tool.setuptools]
package-dir = { "" = "src" }
py-modules = ["gate_policy", "governance_cli", "risk_scoring"]
134 changes: 118 additions & 16 deletions src/gate_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@

from dataclasses import dataclass
from enum import Enum
from typing import Dict, List
from typing import Dict, List, Optional

from risk_scoring import RiskInputs, RiskTier, score_risk


class ReleaseDecision(str, Enum):
GO = "go"
CONDITIONAL_GO = "conditional_go"
NO_GO = "no_go"
"""Canonical release outcomes used by the governance framework."""

RELEASE = "release"
RELEASE_WITH_CONDITIONS = "release_with_conditions"
HOLD = "hold"
DO_NOT_RELEASE = "do_not_release"
DEFER = "defer"

# Backward-compatible symbolic aliases for earlier callers.
GO = RELEASE
CONDITIONAL_GO = RELEASE_WITH_CONDITIONS
NO_GO = DO_NOT_RELEASE


GATE_LABELS = {
Expand Down Expand Up @@ -52,7 +61,7 @@ class ReleaseDecision(str, Enum):
}


CRITICAL_GATES = {
HARD_GATES = {
RiskTier.LOW: ["monitoring_and_observability"],
RiskTier.MEDIUM: [
"fallback_and_degraded_mode",
Expand All @@ -67,6 +76,19 @@ class ReleaseDecision(str, Enum):
],
}

# Backward-compatible alias for callers using the former name.
CRITICAL_GATES = HARD_GATES


@dataclass(frozen=True)
class DecisionContext:
"""Explicit decision facts that cannot be inferred from gate evidence alone."""

critical_failure: bool = False
prohibited_condition: bool = False
unacceptable_residual_risk: bool = False
defer_reason: Optional[str] = None


@dataclass
class ReleaseAssessment:
Expand All @@ -79,26 +101,69 @@ class ReleaseAssessment:
rationale: List[str]


def assess_release(risk_inputs: RiskInputs, evidence: Dict[str, bool]) -> ReleaseAssessment:
def assess_release(
risk_inputs: RiskInputs,
evidence: Dict[str, bool],
decision_context: Optional[DecisionContext] = None,
) -> ReleaseAssessment:
"""Assess a release using risk-tier gates and explicit decision context.

Gate evidence can establish release, conditional release, or hold. A terminal
do-not-release outcome requires an explicit critical/prohibited/unacceptable
condition. Deferral is also explicit because it represents an owner decision,
not merely missing evidence.
"""

unknown_evidence = sorted(set(evidence) - set(GATE_LABELS))
if unknown_evidence:
raise ValueError(
"unsupported evidence fields: " + ", ".join(unknown_evidence)
)
for gate, value in evidence.items():
if not isinstance(value, bool):
raise ValueError(f"evidence.{gate} must be a boolean")

score, tier = score_risk(risk_inputs)
required_gates = REQUIRED_GATES[tier]
satisfied_gates = [gate for gate in required_gates if evidence.get(gate, False)]
missing_gates = [gate for gate in required_gates if not evidence.get(gate, False)]
context = decision_context or DecisionContext()

rationale: List[str] = []

if not missing_gates:
decision = ReleaseDecision.GO
terminal_reasons: List[str] = []
if context.critical_failure:
terminal_reasons.append("a critical failure")
if context.prohibited_condition:
terminal_reasons.append("a prohibited condition")
if context.unacceptable_residual_risk:
terminal_reasons.append("unacceptable residual risk")

if terminal_reasons:
decision = ReleaseDecision.DO_NOT_RELEASE
rationale.append(
"Do not release because the decision context records "
+ ", ".join(terminal_reasons)
+ "."
)
elif context.defer_reason:
decision = ReleaseDecision.DEFER
rationale.append(f"Decision deferred: {context.defer_reason}")
elif not missing_gates:
decision = ReleaseDecision.RELEASE
rationale.append("All required gates for the assessed risk tier are satisfied.")
else:
critical_missing = [gate for gate in CRITICAL_GATES[tier] if gate in missing_gates]
if critical_missing:
decision = ReleaseDecision.NO_GO
rationale.append("One or more critical control gates are missing.")
hard_missing = [gate for gate in HARD_GATES[tier] if gate in missing_gates]
if hard_missing:
decision = ReleaseDecision.HOLD
rationale.append(
"One or more hard control gates are missing; release is on hold pending remediation or evidence."
)
else:
decision = ReleaseDecision.CONDITIONAL_GO
rationale.append("Required gates are incomplete, but no critical gate is missing.")
rationale.append("A conditional release should include owner, deadline, and compensating controls.")
decision = ReleaseDecision.RELEASE_WITH_CONDITIONS
rationale.append("Required non-hard gates remain incomplete, but no hard gate is missing.")
rationale.append(
"A conditional release should include enforceable scope, owner, deadline, monitoring, and stop conditions."
)

if tier == RiskTier.HIGH:
rationale.append("High-risk releases require explicit accountability and incident response readiness.")
Expand Down Expand Up @@ -127,3 +192,40 @@ def build_risk_inputs(data: Dict[str, int]) -> RiskInputs:
observability_maturity=data["observability_maturity"],
fallback_readiness=data["fallback_readiness"],
)


def build_decision_context(data: Dict[str, object]) -> DecisionContext:
"""Build and validate explicit decision context from profile data."""

allowed_fields = {
"critical_failure",
"prohibited_condition",
"unacceptable_residual_risk",
"defer_reason",
}
unknown_fields = sorted(set(data) - allowed_fields)
if unknown_fields:
raise ValueError(
"unsupported decision_context fields: " + ", ".join(unknown_fields)
)

def read_bool(key: str) -> bool:
value = data.get(key, False)
if not isinstance(value, bool):
raise ValueError(f"decision_context.{key} must be a boolean")
return value

defer_reason = data.get("defer_reason")
if defer_reason is not None:
if not isinstance(defer_reason, str):
raise ValueError("decision_context.defer_reason must be a string or null")
defer_reason = defer_reason.strip()
if not defer_reason:
raise ValueError("decision_context.defer_reason must not be blank")

return DecisionContext(
critical_failure=read_bool("critical_failure"),
prohibited_condition=read_bool("prohibited_condition"),
unacceptable_residual_risk=read_bool("unacceptable_residual_risk"),
defer_reason=defer_reason,
)
19 changes: 16 additions & 3 deletions src/governance_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from typing import Any, Dict

from gate_policy import GATE_LABELS, assess_release, build_risk_inputs
from gate_policy import GATE_LABELS, assess_release, build_decision_context, build_risk_inputs


def _load_profile(path: Path) -> Dict[str, Any]:
Expand All @@ -16,9 +16,22 @@ def _load_profile(path: Path) -> Dict[str, Any]:


def _serialize_report(profile: Dict[str, Any]) -> Dict[str, Any]:
risk_inputs = profile.get("risk_inputs")
if not isinstance(risk_inputs, dict):
raise ValueError("risk_inputs must be an object")

evidence = profile.get("evidence", {})
if not isinstance(evidence, dict):
raise ValueError("evidence must be an object")

decision_context = profile.get("decision_context", {})
if not isinstance(decision_context, dict):
raise ValueError("decision_context must be an object")

assessment = assess_release(
build_risk_inputs(profile["risk_inputs"]),
profile.get("evidence", {}),
build_risk_inputs(risk_inputs),
evidence,
build_decision_context(decision_context),
)

return {
Expand Down
Loading