Files
acdl/core/hitl_gates.py
T
Jon Chery f8616b806e feat(P1): event emitters — CloudEvents envelope, Decision Ledger, Infracost adapter, attestation/confidence/policy event emission
P1 (Wave 1, feat) — REQ-187, REQ-188, REQ-205 (emitter), REQ-206 (emitter)

New components:
- core/metrics/event_envelope.py — CloudEvents 1.0 envelope + platform.* conventions
- core/metrics/run_manifest.py — per-run manifest writer (nova.run.started/completed/failed)
- core/metrics/decision_ledger.py — SQLite append-only hash-chain (ai.decision.made + attestation.recorded)
- core/metrics/infracost_adapter.py — Infracost post-processor (degraded mode when CLI absent, A6)
- schemas/metrics_event.schema.json — CloudEvents envelope schema
- schemas/metrics_run_manifest.schema.json — per-run manifest schema
- metrics/README.md — backup/restore doc (REQ-201)
- tests/test_metrics_emitters.py — 16 tests (all pass)

Modified components:
- core/confidence_signal.py — emits nova.confidence.computed + nova.ai.decision.made (D-122)
- core/hitl_gates.py — emits nova.attestation.recorded on qa/prod/dr gates (D-132)
- adapters/terraform/policy/checkov_adapter.py — emits nova.policy.evaluated
- pyproject.toml — addopts gains --junitxml + --json-report + --cov (REQ-206)
- .gitignore — metrics runtime artifacts ignored

D-120: Nova-native (JSONL + SQLite, no Kafka/OTel)
D-121: Decision Ledger = outbox_writer extension → SQLite hash-chain
D-122: AI decision = confidence_signal + HITL gate (not LLM)
D-128: metrics/ at repo root
D-132: Attestation instrumentation

---ci---
project: acdl
phase: 1
milestone: v1.17
status: execute
---/ci---
2026-08-04 19:58:54 +00:00

113 lines
4.1 KiB
Python

"""HITL pre-execution attestation gates (REQ-108, D-084).
Records the approver identity (`gitea.actor` / `github.actor`) to the
DynamoDB outbox for the contractId (attribute `approver_qa` /
`approver_prod` / `approver_dr`), runs the separation-of-duties check on
prod, invokes the 8-concern attestation matrix for the target env, and
returns (ok, reason). Dev skips (autonomous). `scripts/run_platform.sh`
calls `attest` before apply for qa/prod/dr.
"""
import os
import sys
from typing import Optional, Tuple
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.metrics.event_envelope import make_event, append_event
from core.metrics.decision_ledger import append as ledger_append
def _approver_attr(env: str) -> str:
return {"qa": "approver_qa", "prod": "approver_prod", "dr": "approver_dr"}.get(env, "")
def attest(contract_id: str, env: str, approver: str,
evidence: Optional[dict] = None,
outbox_client=None) -> Tuple[bool, str]:
"""Attest a promotion gate for the given environment.
Args:
contract_id: the contract UUID.
env: dev/qa/prod/dr.
approver: the approver's username (`gitea.actor` / `github.actor`).
evidence: optional operator-supplied evidence artifacts (for the
attestation matrix operator-supplied concerns).
outbox_client: optional moto-mocked DynamoDB outbox client for tests.
Returns:
(ok, reason). ok=False means block the promotion.
"""
if env == "dev":
return (True, "dev autonomous (no HITL gate)")
if not approver:
return (False, f"no approver identity for {env} (GITHUB_ACTOR/GITEA_ACTOR unset)")
attr = _approver_attr(env)
if not attr:
return (False, f"unknown environment: {env}")
# Record the approver to the outbox.
if outbox_client is not None:
outbox_client.put_approver(contract_id, attr, approver)
# Run the separation-of-duties check on prod.
if env == "prod":
from core.separation_of_duties import check as sod_check, route_halt_artifact
ok, reason = sod_check(outbox_client, contract_id, approver)
if not ok:
route_halt_artifact(contract_id, reason, oncall_client=None)
return (False, reason)
# Run the 8-concern attestation matrix.
from core.attestation_matrix import check as matrix_check
ok, reason = matrix_check(env, evidence or {})
if not ok:
return (False, reason)
# Emit attestation.recorded event to the Decision Ledger (D-132).
try:
run_id = os.environ.get("NOVA_RUN_ID", f"attest-{contract_id[:8]}")
attestation_data = {
"approver": approver,
"environment": env,
"concerns": reason,
"result": "pass",
"contract_id": contract_id,
}
attestation_event = make_event("nova.attestation.recorded", run_id, env, attestation_data,
contract_id=contract_id, actor_type="human-attestation",
actor_id=approver)
append_event(attestation_event)
ledger_append(attestation_event)
except Exception:
pass # metrics emission must never break the attestation gate
return (True, f"{env} attested by {approver}")
def approver_from_env() -> Optional[str]:
"""Read the approver identity from the environment."""
return os.environ.get("GITHUB_ACTOR") or os.environ.get("GITEA_ACTOR")
if __name__ == "__main__":
# CLI: hitl_gates.py <contract_id> <env> [evidence.json]
if len(sys.argv) < 3:
print("usage: hitl_gates.py <contract_id> <env> [evidence.json]", file=sys.stderr)
sys.exit(2)
_cid = sys.argv[1]
_env = sys.argv[2]
_evidence = {}
if len(sys.argv) >= 4 and os.path.isfile(sys.argv[3]):
import json
with open(sys.argv[3]) as f:
_evidence = json.load(f)
_approver = approver_from_env() or ""
ok, reason = attest(_cid, _env, _approver, _evidence)
if ok:
print(f"HITL PASS: {reason}")
sys.exit(0)
else:
print(f"HITL BLOCK: {reason}", file=sys.stderr)
sys.exit(1)