feat(P2): Nova rebrand — code/env-vars/consumer-path (REQ-158/159/160)

core/env.py dual-read helper (D-108); 21 ACDL_*→NOVA_* env vars migrated
across core/scripts/adapters/tests/workflows + .env/.env.secrets (key
rename, values stay). G-106 binding: run_platform.sh:288-289 +
regression_verify.py:309-312 dual-read (NOVA first, ACDL fallback).
G-108 binding: Gitea NOVA_* secrets created via API + workflow secrets:
refs updated (deploy.yml + modules-lifecycle.yml, .gitea + .github).
acdl_tagging.py→nova_tagging.py (D-109 warn mode, nova:* enforced).
.acdl/→.nova/ consumer path (resolver + deploy workflow + schema +
tests + docs). Test fixtures updated; pytest + run_ci.sh PASS.

---ci---
project: acdl
phase: 2
milestone: v1.15
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-07-30 01:25:24 +00:00
parent 0bc70a3d95
commit d5bae868a4
48 changed files with 593 additions and 297 deletions
+14 -4
View File
@@ -14,7 +14,8 @@ concerns split into two tiers:
The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window from §10.4. Signature verification runs
when `ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged
when `NOVA_ATTESTATION_SIGNING_KEY_ID` is set (dual-read via core/env.py:
NOVA_* preferred, ACDL_* fallback until P5); it is skipped + logged
when unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr.
"""
@@ -24,6 +25,14 @@ import os
import sys
from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
# Freshness windows (days) from hitl_matrix_design.md §10.4.
FRESHNESS_DAYS = {
@@ -81,14 +90,15 @@ def _is_fresh(artifact: dict, concern: str) -> bool:
def _verify_signature(artifact: dict) -> bool:
"""Verify the JWS detached signature when ACDL_ATTESTATION_SIGNING_KEY_ID is set.
"""Verify the JWS detached signature when NOVA_ATTESTATION_SIGNING_KEY_ID is set.
When unset (dev/CI — D-089), signature verification is skipped + logged.
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
"""
key_id = os.environ.get("ACDL_ATTESTATION_SIGNING_KEY_ID", "")
key_id = env.get_env("ATTESTATION_SIGNING_KEY_ID", "") or ""
if not key_id:
sys.stderr.write(
"[attestation] ACDL_ATTESTATION_SIGNING_KEY_ID unset — "
"[attestation] NOVA_ATTESTATION_SIGNING_KEY_ID unset — "
"signature verification skipped (dev/CI, D-089)\n"
)
return True
+14 -3
View File
@@ -36,6 +36,16 @@ import sys
import yaml
import jsonschema
# Ensure the repo root (parent of core/) is on sys.path so `from core
# import env` resolves to THIS package when contract_resolver.py is run
# as a script (python3 core/contract_resolver.py) — otherwise an
# editable-installed third-party `core` package can shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def _load_env(env_name, repo_root):
"""Load the environment onboarding JSON for env_name.
@@ -620,9 +630,10 @@ if __name__ == "__main__":
idx = sys.argv.index("--environment")
if idx + 1 < len(sys.argv):
env_override = sys.argv[idx + 1]
# Also honor the ACDL_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
if env_override is None and os.environ.get("ACDL_ENVIRONMENT_OVERRIDE"):
env_override = os.environ["ACDL_ENVIRONMENT_OVERRIDE"]
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
# Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
if env_override is None and env.get_env("ENVIRONMENT_OVERRIDE"):
env_override = env.get_env("ENVIRONMENT_OVERRIDE")
result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh:
json.dump(result, fh, indent=2)
+48
View File
@@ -0,0 +1,48 @@
"""Dual-read environment helper (D-108, REQ-159, G-106).
During the Nova rebrand transition window (P2P4), every `NOVA_*`
environment variable is the preferred source, with the legacy `ACDL_*`
name as the fallback. This keeps deployments from breaking while the
keys are rotated across `.env`, `.env.secrets`, Gitea repo secrets, and
operator-managed process environments.
`get_env(name, default=None)` resolves `NOVA_<name>` first, then falls
back to `ACDL_<name>`, then returns `default` if neither is set.
This helper is removed (NOVA-only) in P5 (REQ-164). Direct-read paths
that bypass this helper (the `.env.secrets` shell export in
`scripts/run_platform.sh` and the Python parser in
`core/regression_verify.py`) mirror this contract inline per the G-106
binding — see those sites for the dual-read shell/Python forms.
"""
from __future__ import annotations
import os
from typing import Optional
__all__ = ["get_env"]
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a config value with a NOVA-preferred / ACDL-fallback read.
`name` is the bare key WITHOUT the prefix (e.g. ``"AWS_ACCOUNT_ID"``).
The lookup order is:
1. ``NOVA_<name>`` (preferred)
2. ``ACDL_<name>`` (legacy fallback, removed in P5)
3. ``default``
Returns the first value that is present and non-empty, or ``default``
if neither env var is set. An explicitly-set empty string is treated
as "unset" so an operator cannot accidentally shadow the fallback
with a blank NOVA key.
"""
nova_val = os.environ.get(f"NOVA_{name}")
if nova_val:
return nova_val
acdl_val = os.environ.get(f"ACDL_{name}")
if acdl_val:
return acdl_val
return default
+3 -3
View File
@@ -93,7 +93,7 @@ The full table (lifted verbatim from §10.4):
The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window above. Signature verification runs when
`ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when
`NOVA_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when
unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr.
@@ -140,7 +140,7 @@ not Kyverno (in v1). Sequence:
in the same process that has authority to block the promotion.
v1.9 implements `route_halt_artifact` as a real SNS publish (topic
`acdl-sod-halt`, ARN from `ACDL_SOD_HALT_TOPIC_ARN`) with an outbox-event
`acdl-sod-halt`, ARN from `NOVA_SOD_HALT_TOPIC_ARN`) with an outbox-event
fallback when the topic ARN is unset (REQ-107). The attestation gate
itself is `core/hitl_gates.py` (`attest(contract_id, env, approver,
evidence)`), which records the approver to the outbox, runs the SoD
@@ -171,5 +171,5 @@ v1.9 (Phase 41 + Phase 42) wires the gates end-to-end:
concerns run for real; operator-supplied concerns accept signed
evidence artifacts validated for freshness + schema.
- **D-089** (v1.9) — attestation artifact signature verification is
skipped when `ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI);
skipped when `NOVA_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI);
required for prod/dr.
+16 -4
View File
@@ -12,7 +12,8 @@ evidence event) runs end-to-end against the local tier with no AWS:
Each adapter exposes the same interface as the live counterpart so the
caller code path is unchanged; only the I/O target swaps. Selection is
gated on the ACDL_LOCAL_TIER env var (set by run_platform.sh --local).
gated on the NOVA_LOCAL_TIER env var (set by run_platform.sh --local).
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
"""
from __future__ import annotations
@@ -32,12 +33,20 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
ROOT = Path(__file__).resolve().parent.parent
def is_local_tier() -> bool:
"""True when the local emulating tier is active."""
return os.environ.get("ACDL_LOCAL_TIER", "") == "1"
return env.get_env("LOCAL_TIER", "") == "1"
# ---------------------------------------------------------------------------
@@ -286,7 +295,7 @@ class LocalLambdaStub:
Returns the handler's response dict
({statusCode, body}). The handler's DynamoDB calls are
intercepted via the ACDL_LOCAL_TIER env var (the handler checks
intercepted via the NOVA_LOCAL_TIER env var (the handler checks
_get_dynamodb(); under local tier it would need patching - we
patch the module's _get_dynamodb to return a local stub)."""
# Import the handler module (the dir is named `lambda`, a Python
@@ -490,6 +499,9 @@ def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[
if __name__ == "__main__":
contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml"
os.environ["ACDL_LOCAL_TIER"] = "1"
# Set both so the dual-read in is_local_tier() finds NOVA_* (preferred);
# the ACDL_* alias stays for any unmigrated reader until P5.
os.environ["NOVA_LOCAL_TIER"] = "1"
os.environ["ACDL_LOCAL_TIER"] = "1" # legacy alias (dual-read fallback), removed in P5
result = run_local_e2e(contract)
print(json.dumps(result, indent=2))
+17 -7
View File
@@ -21,8 +21,16 @@ try:
except ImportError:
boto3 = None
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
SSM_PREFIX = "/acdl"
KMS_KEY_ID_ENV = "ACDL_KMS_KEY_ID"
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
# Outputs that are safe to display in a PR comment (no secrets).
SAFE_OUTPUT_NAMES = {
@@ -54,20 +62,22 @@ def _ssm_client():
def _kms_key_id():
"""Return the KMS key ID for SSM SecureString encryption.
P1-3: Fail loud when ACDL_KMS_KEY_ID is not set — silently falling back
P1-3: Fail loud when NOVA_KMS_KEY_ID is not set — silently falling back
to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform
CMK must be explicitly configured. Set ACDL_ALLOW_DEFAULT_KMS=1 to use
the AWS-managed key as an escape hatch for local testing.
CMK must be explicitly configured. Set NOVA_ALLOW_DEFAULT_KMS=1 to use
the AWS-managed key as an escape hatch for local testing. (Dual-read
via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.)
"""
key_id = os.environ.get(KMS_KEY_ID_ENV)
key_id = _envhelper.get_env("KMS_KEY_ID")
if key_id:
return key_id
if os.environ.get("ACDL_ALLOW_DEFAULT_KMS") == "1":
if _envhelper.get_env("ALLOW_DEFAULT_KMS") == "1":
return "alias/aws/ssm"
raise RuntimeError(
f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key "
f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set "
f"ACDL_ALLOW_DEFAULT_KMS=1 to use alias/aws/ssm (escape hatch for local testing)."
f"NOVA_ALLOW_DEFAULT_KMS=1 (ACDL_ALLOW_DEFAULT_KMS=1 fallback) to use "
f"alias/aws/ssm (escape hatch for local testing)."
)
+20 -6
View File
@@ -32,6 +32,15 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when regression_verify.py is run as a script (avoids editable-installed
# third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
ROOT = Path(__file__).resolve().parent.parent
CIAgent = ROOT / ".ciagent"
@@ -306,9 +315,13 @@ def _load_aws_env() -> Dict[str, str]:
continue
if "=" in line:
k, v = line.split("=", 1)
if k == "ACDL_AWS_ACCESS_KEY_ID":
# G-106 binding: dual-read NOVA_* first, ACDL_* fallback.
# The .env.secrets keys are renamed to NOVA_* in P2; the
# ACDL_* fallback covers operators who haven't rotated
# their local .env.secrets yet. Removed in P5.
if k == "NOVA_AWS_ACCESS_KEY_ID" or k == "ACDL_AWS_ACCESS_KEY_ID":
env["AWS_ACCESS_KEY_ID"] = v
elif k == "ACDL_AWS_SECRET_ACCESS_KEY":
elif k == "NOVA_AWS_SECRET_ACCESS_KEY" or k == "ACDL_AWS_SECRET_ACCESS_KEY":
env["AWS_SECRET_ACCESS_KEY"] = v
elif k == "AWS_DEFAULT_REGION":
env["AWS_DEFAULT_REGION"] = v
@@ -319,7 +332,8 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
"""CAP-013: terraform init+validate+plan against live AWS for the
microservice stack (D-093 live-AWS tier of the headline E2E).
Requires AWS credentials (ACDL_AWS_ACCESS_KEY_ID etc. in .env.secrets).
Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
dual-read NOVA_* first, ACDL_* fallback per G-106).
Runs in a temp dir; does NOT apply (plan only)."""
import tempfile, os
work = tempfile.mkdtemp(prefix="acdl_regr_live_")
@@ -421,7 +435,7 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
@@ -643,8 +657,8 @@ def write_report(report: RegressionReport,
def main() -> int:
milestone = os.environ.get("ACDL_REGRESSION_MILESTONE", "v1.10")
phase = int(os.environ.get("ACDL_REGRESSION_PHASE", "52"))
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
report = run_regression(milestone=milestone, phase=phase)
md, js = write_report(report)
print(f"regression: {report.summary} -> {md}")
+15 -4
View File
@@ -5,16 +5,27 @@ Blocks on equality, emits `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt
artifact to SRE on-call.
v1.9 (REQ-107, D-085): route_halt_artifact is a real implementation —
publishes to SNS topic `acdl-sod-halt` (ARN from ACDL_SOD_HALT_TOPIC_ARN)
publishes to SNS topic `acdl-sod-halt` (ARN from NOVA_SOD_HALT_TOPIC_ARN)
when set; falls back to a structured stderr emission + a
SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox when
unset. No silent print-only stub.
unset. No silent print-only stub. (Dual-read via core/env.py: NOVA_*
preferred, ACDL_* fallback until P5; the SNS topic ARN is the AWS
resource `acdl-sod-halt` → renamed `nova-sod-halt` in P4.)
"""
import os
import sys
from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when imported/run in a context where an editable-installed third-party
# `core` package would otherwise shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def check(outbox_client, contract_id: str,
current_prod_approver: Optional[str]) -> Tuple[bool, str]:
@@ -40,13 +51,13 @@ def route_halt_artifact(contract_id: str, violation_reason: str,
oncall_client=None) -> None:
"""Route a halt artifact to SRE on-call (REQ-107, D-085).
When ACDL_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via
When NOVA_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via
boto3. When unset (dev/CI), fall back to a structured stderr emission
+ a SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox
via outbox_writer.write_event (so the halt is in the audit chain).
The oncall_client, when provided, is the SNS client (test injection).
"""
topic_arn = os.environ.get("ACDL_SOD_HALT_TOPIC_ARN", "")
topic_arn = env.get_env("SOD_HALT_TOPIC_ARN", "") or ""
halt_payload = {
"contractId": contract_id,
"reason": violation_reason,