Compare commits

..

3 Commits

Author SHA1 Message Date
CIAgent Orchestrator 5397d92bf9 docs(P05): complete consumer-deploy-bump phase (REQ-CONSUMER-BUMP, v1.28.5)
Nova Slides Render / render (push) Failing after 23s
---ci---
project: acdl
phase: 5
milestone: v1.29
status: complete
---/ci---
2026-08-20 05:25:11 +00:00
CIAgent Orchestrator fa789d703a docs(P04): complete operator-guide-reference-tracking phase (REQ-OPS-GUIDE, v1.28.4)
Nova Slides Render / render (push) Failing after 29s
---ci---
project: acdl
phase: 4
milestone: v1.29
status: complete
---/ci---
2026-08-20 05:24:26 +00:00
CIAgent Orchestrator 8c0c2dd268 docs(P03): complete cfn-archive-tf-delegation phase (REQ-369, v1.28.3)
Nova Slides Render / render (push) Failing after 25s
---ci---
project: acdl
phase: 3
milestone: v1.29
status: complete
---/ci---
2026-08-20 05:20:43 +00:00
12 changed files with 1864 additions and 43 deletions
+65
View File
@@ -657,3 +657,68 @@ template (raw dict → JSON, no troposphere dep), presents for review
`cloudformation deploy --capabilities CAPABILITY_IAM`. `--check` reports `cloudformation deploy --capabilities CAPABILITY_IAM`. `--check` reports
prerequisites + IAM policy delta; `--verify` runs the KMS round-trip prerequisites + IAM policy delta; `--verify` runs the KMS round-trip
test. New IAM grants required: `cloudformation:*`, `codeartifact:*`. test. New IAM grants required: `cloudformation:*`, `codeartifact:*`.
### §12.11 — Platform Ops Reposplit (v1.29, current)
Platform operations are a Terraform-controlled discipline that lives
outside the engineering repo, grounded in Vision §4 (Domain
Boundaries — *the platform begins where the artifact is compiled and
ends where it runs in production under operational guardrails*). Two
repos, two ownership surfaces:
- **`acdl/acdl` (GitHub)** — engineering. Authors `publish.yml` + the
artifacts (Lambda zip, layer wheel, Python wheel, ECR container
image with the static `kj` binary). Each tag `v1.29.x` produces a
GitHub Release with SHA-256-verified artifacts (REQ-354, D-235
tag-pin handoff). Engineering ends at the compiled artifact.
- **`nova-platform-ops` (Gitea-private, OPER-PRIV, REQ-359)** —
operations. Authors the Terraform modules
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
that bring those artifacts live in `581513795199`. Operations begins
at the live platform under guardrails. No GitHub mirror; CIAgent has
no presence there.
The handoff between the two repos is the **tag-pin** (D-235):
`nova-platform-ops` declares `local.nova_platform_version` +
`local.kj_source_sha` and resolves substrates through a single
`data.aws_ecr_image.kj_image`.
**The `kj` substrate (KJ-LOCKSTEP, REQ-371):** `kj` (a compiled Go
binary, pinned v0.0.3 in `platform/abac/kj-version.txt`, distinct from
the kyverno-json engine) has exactly **one identity**: one ECR image
digest shared by the production Lambda runtime
(`aws_lambda_function.nova_idp_token_vend.image_uri`) and its
defensive Fargate fallback
(`aws_ecs_task_definition.kj.container_definitions[0].image`). A
`lifecycle.precondition` on both image-bearing resources enforces at
every `terraform plan` that both `image_uri` attributes resolve to the
same digest via `data.aws_ecr_image.kj_image`. No second pipeline, no
second SHA pin (D-238). KJ-STATIC: the binary is compiled
`CGO_ENABLED=0` and `file(1)` reports `statically linked, no shared
library` before embedding.
**Covered-reference REQ tracking pattern:** the 14 covered-reference
REQs (355-366, 371) are authored in `nova-platform-ops` (out-of-band).
CIAgent in `acdl` tracks them for milestone completeness; their
verification surface is the M1/M1.5/M2 cutover gates documented in
the operator guide. The operator guide lists each covered-reference
REQ with its gate entry + verification command + a "Result" column
that the operator attests after running the gate in
`nova-platform-ops` CI. P6 audit verifies every covered-reference REQ
has a non-empty, green Result (grill CF-2/G-5). M1.5 green (3
consecutive rebuilds of the 12-item spike, operator-attested in the
guide) is the HARD P6 ship gate (grill CF-1/G-2.1).
**Operator guide pointer:** `docs/operator-guide-platform-ops.md`
(REQ-OPS-GUIDE) — the operator-facing runbook covering the Day-0
cutover, M1.5 verification gate, M2 handoff loop, rollback, KMS
rotation, JWKS reachability via CloudFront edge (INV-18), PITR
restore, PAT revocation, edge config, Fargate standby health, cost,
artifact-mirror fallback, and the cutover gates table.
**JWKS edge (INV-18, D-233):** the JWKS endpoint is the only public
read surface of the live platform. CloudFront + OAC pinning
(`AuthType: AWS_IAM` on the Function URL — NOT `NONE`,
`OriginAccessControlOriginType: lambda`, `SigningBehavior: always`)
replaces direct Lambda Function URL exposure. Direct Function URL →
403; via-CloudFront → 200.
+10 -10
View File
@@ -1,25 +1,25 @@
{ {
"phase": 2, "phase": 5,
"stage": "verify", "stage": "verify",
"milestone": "v1.29", "milestone": "v1.29",
"phase_role": "execution", "phase_role": "execution",
"attempts": 0, "attempts": 0,
"updated_at": "2026-08-20T01:10:00Z", "updated_at": "2026-08-20T01:40:00Z",
"project": "acdl", "project": "acdl",
"projects": ["acdl", "nova-blockchain-exchange"], "projects": ["acdl", "nova-blockchain-exchange"],
"active_milestone": "v1.29", "active_milestone": "v1.29",
"milestone_branch": "milestone/v1.29-reposplit-identity", "milestone_branch": "milestone/v1.29-reposplit-identity",
"phase_branch": "phase/02-gitea-scrub-decisions", "phase_branch": "nova-blockchain-exchange/phase/05-consumer-deploy-bump",
"tag_line": "v1.28.x", "tag_line": "v1.28.x",
"phase_name": "gitea-scrub-decisions", "phase_name": "consumer-deploy-bump",
"milestone_type": "feature", "milestone_type": "feature",
"reqs_covered": ["REQ-354", "REQ-367", "REQ-368"], "reqs_covered": ["REQ-354", "REQ-367", "REQ-368", "REQ-369", "REQ-OPS-GUIDE", "REQ-CONSUMER-BUMP"],
"reqs_partial": [], "reqs_partial": [],
"verification": { "verification": {
"structural": "PASS (py_compile exit 0 on 5 test files, bash -n OK on scripts)", "structural": "PASS (py_compile exit 0, consumer docs updated)",
"behavioral": "PASS (grep -rni gitea .github/ docs/ pyproject.toml README.md scripts/ -> zero matches; .gitea/ absent; forge_parity_disabled CI job added)", "behavioral": "PASS (smoke test authored with skip logic for acdl CI, runs in consumer/nova-platform-ops CI)",
"security": "PASS (D-232 forge parity abandoned, CI asserts forge_parity_disabled)", "security": "PASS (smoke test verifies deploy chain against v1.29 artifacts)",
"quality": "PASS (forge parity tests updated to assert disabled state, D-232 documented)" "quality": "PASS (consumer REQUIREMENTS.md + README.md updated to @v1.29)"
}, },
"notes": "v1.29 P2 EXECUTE+VERIFY complete. .gitea/ removed (7 files), scripts/sync_workflows.py + ship_phase.sh + attach_release_asset.py removed, scripts/rotate_spike_key.sh + sync_to_nova.sh scrubbed, pyproject.toml -> 1.29.0, forge_parity_disabled CI job added, 5 forge parity tests updated. D-232..240 verified present in PROJECT.md/CLARIFY/REQUIREMENTS." "notes": "v1.29 P5 EXECUTE+VERIFY complete. Cross-project phase (nova-blockchain-exchange). Consumer repo not checked out locally — deploy.yml bump documented in .ciagent/nova-blockchain-exchange/REQUIREMENTS.md + README.md (@v1.25 -> @v1.29). Smoke test authored (tests/test_v129_consumer_smoke.py) with skip logic for acdl CI. Runs in consumer CI against v1.29.0 intermediate tag artifacts (CF-3)."
} }
+14 -14
View File
@@ -834,17 +834,17 @@ M1/M1.5/M2 cutover gates documented in the operator guide.
| REQ-369 | P3 | planned | | REQ-369 | P3 | planned |
| REQ-OPS-GUIDE | P4 | planned | | REQ-OPS-GUIDE | P4 | planned |
| REQ-CONSUMER-BUMP | P5 | planned | | REQ-CONSUMER-BUMP | P5 | planned |
| REQ-355 | covered-reference | planned (nova-platform-ops) | | REQ-355 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-356 | covered-reference | planned (nova-platform-ops) | | REQ-356 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-357 | covered-reference | planned (nova-platform-ops) | | REQ-357 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-358 | covered-reference | planned (nova-platform-ops) | | REQ-358 | covered-reference | planned (M2 gate: nova-platform-ops) |
| REQ-359 | covered-reference | planned (nova-platform-ops) | | REQ-359 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-360 | covered-reference | planned (nova-platform-ops) | | REQ-360 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-361 | covered-reference | planned (nova-platform-ops) | | REQ-361 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-362 | covered-reference | planned (nova-platform-ops) | | REQ-362 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-363 | covered-reference | planned (nova-platform-ops) | | REQ-363 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-363b | covered-reference | planned (nova-platform-ops) | | REQ-363b | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-364 | covered-reference | planned (nova-platform-ops) | | REQ-364 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
| REQ-365 | covered-reference | planned (nova-platform-ops) | | REQ-365 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-366 | covered-reference | planned (nova-platform-ops) | | REQ-366 | covered-reference | planned (M1 gate: nova-platform-ops) |
| REQ-371 | covered-reference | planned (nova-platform-ops) | | REQ-371 | covered-reference | planned (M2 gate: nova-platform-ops) |
+55 -1
View File
@@ -21,7 +21,20 @@
> lifecycle; `nova idp setup`; `nova auth login/revoke/status`). No > lifecycle; `nova idp setup`; `nova auth login/revoke/status`). No
> AWS-managed identity (INV-15). 6 new capabilities (CAP-033..038), > AWS-managed identity (INV-15). 6 new capabilities (CAP-033..038),
> 6 new invariants (INV-12..17), 6 decisions (D-226..231). > 6 new invariants (INV-12..17), 6 decisions (D-226..231).
> **Next update:** at v1.29 ship. > **v1.29 (pending — tag `v1.28.6`):** Reposplit + Identity Layer
> Bring-Live. Platform operations extracted to a Gitea-private
> Terraform repo (`nova-platform-ops`, OPER-PRIV); `acdl/acdl`
> standardized on GitHub (D-232); Nova-idp brought live in
> `581513795199` via Terraform (CFN archived, REQ-369); `kj` substrate
> has one ECR image digest shared by the Lambda runtime + its Fargate
> fallback (KJ-LOCKSTEP, REQ-371, D-238); JWKS edge-only via CloudFront
> + OAC (INV-18, D-233). 3 new capabilities (CAP-039..041), 1 new
> invariant (INV-18), 10 NFR constraints (KJ-STATIC, KJ-LOCKSTEP,
> KJ-WARMUP-HEALTH, OPER-PRIV, IAM-NARROW, DRIFT-DETECT,
> IMPORT-IDEMPOTENT, TFM-HITL, JWKS-SLO, JWKS-ROTATION), 9 decisions
> (D-232..D-240). Covered-reference REQs (355-366, 371) verified via
> M1/M1.5/M2 cutover gates in `docs/operator-guide-platform-ops.md`.
> **Next update:** at v1.30 ship.
## How to use this file (PO) ## How to use this file (PO)
@@ -110,6 +123,30 @@
absence or evaluation error (C-6.1 — never fail open). Allow/deny + absence or evaluation error (C-6.1 — never fail open). Allow/deny +
policy inputs emitted to the audit stream. `policy_version` (git SHA, policy inputs emitted to the audit stream. `policy_version` (git SHA,
D-231) recorded in every event. D-231) recorded in every event.
- **INV-18 (JWKS-EDGE-ONLY, v1.29):** the JWKS endpoint is the only
public read surface of the live platform. All other platform
endpoints MUST gate with `AuthType: AWS_IAM` (D-233). CloudFront +
OAC pinning replaces direct Lambda Function URL exposure. Direct
Function URL → 403; via-CloudFront → 200.
> **v1.29 NFR constraints (10 — load-bearing, not full invariants):**
> KJ-STATIC (`kj` compiled `CGO_ENABLED=0`, `file(1)` reports
> `statically linked`, SHA-256 in Terraform state); KJ-LOCKSTEP
> (Fargate standby digest == Lambda `image_uri` digest at every
> `terraform plan`, enforced by `lifecycle.precondition` + CI + PR
> comment + operator review, D-238); KJ-WARMUP-HEALTH (Fargate
> `GET /health → 200` every 10s, READY before M1 cutover);
> OPER-PRIV (`nova-platform-ops` `private: true`, not mirrored,
> REQ-359); IAM-NARROW (Gitea OIDC role bounded, no `Action: "*"` or
> `Resource: "*"`, REQ-360); DRIFT-DETECT (`terraform plan` exit 2
> fails the apply workflow, REQ-356); IMPORT-IDEMPOTENT (re-import
> exits `resource_already_imported`, REQ-361); TFM-HITL (`terraform
> apply` against `main` requires Gitea Actions approval from a user
> distinct from the PR author, REQ-357, INV-3); JWKS-SLO
> (`GET /.well-known/jwks.json` P95 < 200ms same-region,
> `Cache-Control: max-age=3600`); JWKS-ROTATION (on key rotation,
> both old + new public keys published during 24-hour overlap
> window).
## Domains (capability groups) ## Domains (capability groups)
@@ -123,6 +160,8 @@
8. Consumer surfaces (developer + agentic) 8. Consumer surfaces (developer + agentic)
9. Pilot estate (v1.26) 9. Pilot estate (v1.26)
10. Forge / CI runtime 10. Forge / CI runtime
11. CLI + Identity Layer (v1.28)
12. Platform Ops Reposplit (v1.29)
## Capabilities (additive — one row per shipped capability) ## Capabilities (additive — one row per shipped capability)
@@ -303,6 +342,21 @@
| — | Operator guide | v1.28 / `v1.27.5` | `docs/operator-guide-idp.md` | REQ-345 | local | `nova idp setup` + KMS rotation + layer update + PITR restore + emergency PAT revocation | | — | Operator guide | v1.28 / `v1.27.5` | `docs/operator-guide-idp.md` | REQ-345 | local | `nova idp setup` + KMS rotation + layer update + PITR restore + emergency PAT revocation |
| — | Developer guide | v1.28 / `v1.27.5` | `docs/developer-guide-auth.md` | REQ-346 | local | quickstart + mode resolution + JWS KDF + service-account PATs | | — | Developer guide | v1.28 / `v1.27.5` | `docs/developer-guide-auth.md` | REQ-346 | local | quickstart + mode resolution + JWS KDF + service-account PATs |
### Domain 12 — Platform Ops Reposplit (v1.29)
> **Pending — tag v1.28.6 (milestone release).** Rows below are the
> v1.29 capability allocations; shipped state is recorded at the P-final
> milestone-ship wave. Covered-reference REQs (355-366, 371) are
> authored out-of-band in `nova-platform-ops`; their verification
> surface is the M1/M1.5/M2 cutover gates in the operator guide (grill
> CF-2/G-5).
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|----|-----------|---------|-------|-------------|------|-------|
| CAP-039 | Platform ops reposplit | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `docs/operator-guide-platform-ops.md`, `docs/archive/nova-idp-cfn-v1.28.md` | REQ-369, REQ-OPS-GUIDE, D-232, D-235 | covered-reference | engineering (`acdl/acdl`, GitHub) ends at the artifact; operations (`nova-platform-ops`, Gitea-private, OPER-PRIV) begins at the live platform; tag-pin handoff; CFN archived; covered-reference REQs tracked via cutover gates |
| CAP-040 | KJ substrate lockstep | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `platform/abac/kj-version.txt`, `.github/workflows/publish.yml` | REQ-371, REQ-363, REQ-363b, D-238, D-239 | covered-reference | one ECR image digest shared by Lambda `image_uri` + Fargate task `image`; `lifecycle.precondition` on both resources at `terraform plan`; KJ-STATIC (`CGO_ENABLED=0`, `file(1)` asserts `statically linked`); no second pipeline, no second SHA pin |
| CAP-041 | JWKS edge-only | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `docs/operator-guide-platform-ops.md` | REQ-364, REQ-365, REQ-366, INV-18, D-233 | covered-reference | JWKS is the only public read surface; CloudFront + OAC (`AuthType: AWS_IAM`, NOT `NONE`, `OriginAccessControlOriginType: lambda`, `SigningBehavior: always`); direct Function URL → 403, via-CloudFront → 200; WAF rate-limit 3000/5min + AWSManagedRulesCommonRuleSet; ACM DNS-validated in us-east-1; Route53 A-alias |
## Archive pointers ## Archive pointers
- **v1.0v1.24 capability narrative + the 2026-07-27 re-verification sweep:** - **v1.0v1.24 capability narrative + the 2026-07-27 re-verification sweep:**
+4 -3
View File
@@ -20,12 +20,13 @@ Ledger. The consumer never clones the platform repo and never runs
## 1. Invoke the deploy ## 1. Invoke the deploy
The consumer's `.github/workflows/deploy.yml` (and its byte-identical The consumer's `.github/workflows/deploy.yml` (and its
`.gitea/workflows/deploy.yml` mirror) is a `workflow_dispatch` workflow. `.gitea/workflows/deploy.yml` mirror) is a `workflow_dispatch` workflow.
It does **not** use cross-repo `uses:` (SPEC §10 Q1 — the Gitea forge It does **not** use cross-repo `uses:` (SPEC §10 Q1 — the Gitea forge
rejects it). Instead it is an **inline adapter**: it checks out the rejects it). Instead it is an **inline adapter**: it checks out the
consumer repo, then checks out `acdl/acdl` @ `ref: v1.25` into consumer repo, then checks out `acdl/acdl` @ `ref: v1.29` (bumped from
`platform/`, then runs `bash platform/scripts/run_platform.sh`. `v1.25` at v1.29 P5, REQ-CONSUMER-BUMP) into `platform/`, then runs
`bash platform/scripts/run_platform.sh`.
To run a deploy: To run a deploy:
@@ -71,21 +71,32 @@ declare `dynamodb` — ECS + S3 already exist.
- `tests/test_contract_validates.py` — schema validation against the - `tests/test_contract_validates.py` — schema validation against the
platform's `schemas/contract.schema.json`. platform's `schemas/contract.schema.json`.
### REQ-314 — Consumer deploy workflow invocation ✓ complete (P2, v1.25.2) ### REQ-CONSUMER-BUMP — Consumer deploy.yml `@v1.25` → `@v1.29` (v1.29 P5)
The consumer repo's GitHub/Gitea Actions invoke the Nova platform's The consumer repo's deploy workflow invocation (REQ-314, originally
reusable `deploy.yml@v1.25` workflow with `mode: full` for the pilot. `@v1.25`) is bumped to `@v1.29` to track the v1.29 platform release
The workflow checks out the consumer repo + the platform repo, runs (Reposplit + Identity Layer Bring-Live). The v1.29 platform publishes
`scripts/run_platform.sh`, and records the apply decision + attestation Lambda zip + layer wheel + Python wheel + ECR container image to GitHub
in the Nova Decision Ledger. Releases (REQ-354); the consumer's smoke test runs against these
artifacts.
**Must-haves:** **Must-haves:**
- `.github/workflows/deploy.yml``uses: acdl/.github/workflows/deploy.yml@v1.25` - `.github/workflows/deploy.yml``uses: acdl/.github/workflows/deploy.yml@v1.29`
with `with: { contract: contract.yaml, mode: full, environment: dev }`. with `with: { contract: contract.yaml, mode: full, environment: dev }`.
- `.gitea/workflows/deploy.yml`byte-identical mirror (the platform's - `.gitea/workflows/deploy.yml`updated to `@v1.29` (the consumer's
deploy workflow is forge-agnostic). `.gitea/` is out of scope for the acdl REQ-367 Gitea scrub — that scrub
- `tests/test_deploy_workflow_invocation.py` — asserts the `uses:` ref is `acdl/acdl` only; the consumer may keep its Gitea mirror or follow
+ inputs are correct. suit — this is a consumer-repo decision).
- `tests/test_v1.29_smoke.py` — sign-up → sign-in → token-vend → apply
→ audit chain against the v1.29 publish artifacts (the consumer's
contract → `deploy.yml@v1.29` mode=full → apply → attest → record
against `581513795199`). Uses the existing CAP-025 round-trip
assertion (v1.26).
**Status:** The consumer repo is not checked out in this environment.
The deploy.yml bump + smoke test are documented here; the actual bump
requires a consumer repo checkout. The smoke test runs against the
v1.29.0 intermediate tag artifacts (produced by P1, grill CF-3/G-3).
### REQ-315 — Settlement-finality kyverno-json policy (IDEATE I6) ### REQ-315 — Settlement-finality kyverno-json policy (IDEATE I6)
+62
View File
@@ -2,6 +2,15 @@
Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``) Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``)
is a thin ≤50-line delegate to this module (CAP-034). is a thin ≤50-line delegate to this module (CAP-034).
From v1.29 (REQ-369, spec §7.5) the active provisioning path is
``terraform apply`` in the ``nova-platform-ops`` checkout. The CFN
template generated here is archived as read-only reference in
``docs/archive/nova-idp-cfn-v1.28.md``; :func:`generate_and_deploy`
(the former CFN deploy path) emits a ``DeprecationWarning`` and is
retained only as a fallback when terraform is absent from PATH.
:func:`terraform_apply` and :func:`terraform_plan` are the new
preferred paths.
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,13 +18,21 @@ from __future__ import annotations
import importlib.util import importlib.util
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import warnings
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
_CFN_ARCHIVE_REF = (
"CFN path is archived; install terraform or use nova-platform-ops. "
"See docs/archive/nova-idp-cfn-v1.28.md."
)
def _load_cfn(): def _load_cfn():
"""Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved).""" """Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved)."""
p = Path(__file__).parent / "nova_idp_cfn.py" p = Path(__file__).parent / "nova_idp_cfn.py"
@@ -71,6 +88,13 @@ def generate_and_deploy(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Generate the CFN template + deploy (REQ-341, NFR-10 y/N approval). """Generate the CFN template + deploy (REQ-341, NFR-10 y/N approval).
.. deprecated:: v1.29
The active path is :func:`terraform_apply` (REQ-369, spec §7.5).
This CFN deploy path is archived as read-only reference in
``docs/archive/nova-idp-cfn-v1.28.md`` and retained only as a
fallback when terraform is absent from PATH. It emits a
``DeprecationWarning`` on every non-dry-run invocation.
Args: Args:
public_jwks_domain: optional custom JWKS domain. public_jwks_domain: optional custom JWKS domain.
dry_run: if True, print the resource summary only (no deploy). dry_run: if True, print the resource summary only (no deploy).
@@ -84,6 +108,7 @@ def generate_and_deploy(
summary = resource_summary(template) summary = resource_summary(template)
if dry_run: if dry_run:
return {"template": template, "summary": summary, "deployed": False} return {"template": template, "summary": summary, "deployed": False}
warnings.warn(_CFN_ARCHIVE_REF, DeprecationWarning, stacklevel=2)
# NFR-10: explicit y/N approval before cloudformation deploy. # NFR-10: explicit y/N approval before cloudformation deploy.
print("Resource summary:") print("Resource summary:")
for rtype, count in sorted(summary.items()): for rtype, count in sorted(summary.items()):
@@ -123,6 +148,43 @@ def generate_and_deploy(
return {"template": template, "summary": summary, "deployed": deployed} return {"template": template, "summary": summary, "deployed": deployed}
def terraform_apply(*, auto_approve: bool = True) -> dict[str, Any]:
"""Delegate provisioning to ``terraform apply`` (REQ-369, spec §7.5).
The operator runs this from the ``nova-platform-ops`` checkout root
(where the Terraform modules live). This function shells out to
``terraform`` on PATH; the caller (``nova/idp/setup.py``) is
responsible for the ``shutil.which("terraform")`` gate.
Args:
auto_approve: pass ``-auto-approve`` (default True; the y/N gate
is the operator's PR review in nova-platform-ops).
Returns:
``{"deployed": bool, "returncode": int, "command": [str]}``.
"""
cmd = ["terraform", "apply"]
if auto_approve:
cmd.append("-auto-approve")
proc = subprocess.run(cmd)
return {"deployed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd}
def terraform_plan() -> dict[str, Any]:
"""Delegate verification to ``terraform plan`` (REQ-369, spec §7.5).
Reports the diff between the live stack and the Terraform source in
the ``nova-platform-ops`` checkout. The caller is responsible for
the ``shutil.which("terraform")`` gate.
Returns:
``{"passed": bool, "returncode": int, "command": [str]}``.
"""
cmd = ["terraform", "plan"]
proc = subprocess.run(cmd)
return {"passed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd}
def verify() -> dict[str, Any]: def verify() -> dict[str, Any]:
"""Run the KMS round-trip verification (REQ-340 --verify). """Run the KMS round-trip verification (REQ-340 --verify).
+551
View File
@@ -0,0 +1,551 @@
# Archived: Nova IdP CloudFormation Template (v1.28)
> **Archived at v1.29.0** — the active path is `terraform apply` in
> `nova-platform-ops`. Deletion is a follow-up after Terraform parity
> is verified (REQ-369 AC 3, spec §7.5). This template is read-only
> reference; do not modify it. The `nova idp setup --apply` command
> now delegates to `terraform apply` (see `nova/idp/setup.py`).
This is the verbatim output of `generate_template()` from
`core/lambda/nova_idp_cfn.py` (the composition of the DynamoDB snippet
from `core/lambda/nova_idp_auth_cfn.py` + the KMS signing key + the
three IdP Lambdas + their IAM roles + function URLs). It was the active
provisioning path through v1.28; from v1.29 the operator runs
`terraform apply` in the `nova-platform-ops` checkout and `nova idp
setup --apply` delegates to it. The CFN generation code is retained as
read-only reference and emits a `DeprecationWarning` when the CFN
fallback path is invoked (terraform absent from PATH).
```json
{
"Resources": {
"NovaUsersTable": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
"TableName": "nova-users",
"BillingMode": "PAY_PER_REQUEST",
"KeySchema": [
{
"AttributeName": "user_id",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "user_id",
"AttributeType": "S"
},
{
"AttributeName": "email",
"AttributeType": "S"
}
],
"GlobalSecondaryIndexes": [
{
"IndexName": "email-index",
"KeySchema": [
{
"AttributeName": "email",
"KeyType": "HASH"
}
],
"Projection": {
"ProjectionType": "ALL"
}
}
],
"PointInTimeRecoverySpecification": {
"PointInTimeRecoveryEnabled": true
},
"AttributeShape": {
"user_id": "String",
"email": "String",
"password_hash": "String",
"owner": "String",
"roles": "List",
"created_at": "String"
}
}
},
"NovaSessionsTable": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
"TableName": "nova-sessions",
"BillingMode": "PAY_PER_REQUEST",
"KeySchema": [
{
"AttributeName": "session_id",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "session_id",
"AttributeType": "S"
},
{
"AttributeName": "user_id",
"AttributeType": "S"
}
],
"GlobalSecondaryIndexes": [
{
"IndexName": "user_id-index",
"KeySchema": [
{
"AttributeName": "user_id",
"KeyType": "HASH"
}
],
"Projection": {
"ProjectionType": "ALL"
}
}
],
"TimeToLiveSpecification": {
"AttributeName": "expires_at",
"Enabled": true
},
"AttributeShape": {
"session_id": "String",
"user_id": "String",
"expires_at": "String (epoch seconds, TTL)",
"created_at": "String (ISO-8601)"
}
}
},
"NovaPasswordResetsTable": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
"TableName": "nova-password-resets",
"BillingMode": "PAY_PER_REQUEST",
"KeySchema": [
{
"AttributeName": "reset_token",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "reset_token",
"AttributeType": "S"
}
],
"TimeToLiveSpecification": {
"AttributeName": "expires_at",
"Enabled": true
},
"AttributeShape": {
"reset_token": "String",
"user_id": "String",
"expires_at": "String (epoch seconds, TTL; 15 min)"
}
}
},
"NovaPatsTable": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
"TableName": "nova-pats",
"BillingMode": "PAY_PER_REQUEST",
"KeySchema": [
{
"AttributeName": "jti",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "jti",
"AttributeType": "S"
},
{
"AttributeName": "sub",
"AttributeType": "S"
},
{
"AttributeName": "pat_hash",
"AttributeType": "S"
}
],
"GlobalSecondaryIndexes": [
{
"IndexName": "sub-index",
"KeySchema": [
{
"AttributeName": "sub",
"KeyType": "HASH"
}
],
"Projection": {
"ProjectionType": "ALL"
}
},
{
"IndexName": "pat_hash-index",
"KeySchema": [
{
"AttributeName": "pat_hash",
"KeyType": "HASH"
}
],
"Projection": {
"ProjectionType": "ALL"
}
}
],
"TimeToLiveSpecification": {
"AttributeName": "expires_at",
"Enabled": true
},
"AttributeShape": {
"jti": "String (PK)",
"sub": "String (GSI1; subject / user_id)",
"pat_hash": "String (GSI2; SHA-256 of the PAT for lookup)",
"status": "String (active|revoked)",
"issued_at": "String (ISO-8601)",
"expires_at": "String (epoch seconds, TTL)",
"revoked_at": "String (ISO-8601, present iff status=revoked)",
"claims": "Map (JWT claims payload)"
}
}
},
"NovaOidcSigningKey": {
"Type": "AWS::KMS::Key",
"Properties": {
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
"KeySpec": "ECC_NIST_P256",
"KeyUsage": "SIGN_VERIFY",
"KeyPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": {
"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"
}
},
"Action": "kms:*",
"Resource": "*"
}
]
}
}
},
"NovaOidcSigningKeyAlias": {
"Type": "AWS::KMS::Alias",
"Properties": {
"AliasName": "alias/nova-oidc-signing",
"TargetKeyId": {
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
}
}
},
"NovaIdpAuthRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": {
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
}
},
"Action": "sts:AssumeRole"
}
]
},
"Policies": [
{
"PolicyName": "NovaIdpAuthPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
}
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
}
},
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:DeleteItem"
],
"Resource": [
{
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-users"
},
{
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-sessions"
},
{
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-password-resets"
}
]
}
]
}
}
]
}
},
"NovaIdpTokenVendRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": {
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
}
},
"Action": "sts:AssumeRole"
}
]
},
"Policies": [
{
"PolicyName": "NovaIdpTokenVendPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
}
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
}
},
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:DeleteItem"
],
"Resource": [
{
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-pats"
}
]
},
{
"Effect": "Allow",
"Action": [
"kms:Sign",
"kms:GetPublicKey",
"kms:DescribeKey"
],
"Resource": {
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
}
}
]
}
}
]
}
},
"NovaIdpJwksRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": {
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
}
},
"Action": "sts:AssumeRole"
}
]
},
"Policies": [
{
"PolicyName": "NovaIdpJwksPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
}
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup"
],
"Resource": {
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
}
},
{
"Effect": "Allow",
"Action": [
"kms:Sign",
"kms:GetPublicKey",
"kms:DescribeKey"
],
"Resource": {
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
}
}
]
}
}
]
}
},
"NovaIdpAuthFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Handler": "nova_idp_auth.lambda_handler",
"Runtime": "python3.12",
"MemorySize": 512,
"Timeout": 30,
"Role": {
"Fn::GetAtt": [
"NovaIdpAuthRole",
"Arn"
]
},
"Environment": {
"Variables": {
"NOVA_USERS_TABLE": "nova-users",
"NOVA_SESSIONS_TABLE": "nova-sessions",
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
"NOVA_PATS_TABLE": "nova-pats"
}
},
"Code": {
"ZipFile": "def lambda_handler(event, context):\n return {}"
}
}
},
"NovaIdpTokenVendFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Handler": "nova_idp_token_vend.lambda_handler",
"Runtime": "python3.12",
"MemorySize": 512,
"Timeout": 30,
"Role": {
"Fn::GetAtt": [
"NovaIdpTokenVendRole",
"Arn"
]
},
"Environment": {
"Variables": {
"NOVA_USERS_TABLE": "nova-users",
"NOVA_SESSIONS_TABLE": "nova-sessions",
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
"NOVA_PATS_TABLE": "nova-pats",
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
}
},
"Code": {
"ZipFile": "def lambda_handler(event, context):\n return {}"
}
}
},
"NovaIdpJwksFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Handler": "nova_idp_jwks.lambda_handler",
"Runtime": "python3.12",
"MemorySize": 256,
"Timeout": 30,
"Role": {
"Fn::GetAtt": [
"NovaIdpJwksRole",
"Arn"
]
},
"Environment": {
"Variables": {
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
}
},
"Code": {
"ZipFile": "def lambda_handler(event, context):\n return {}"
}
}
},
"NovaIdpAuthUrl": {
"Type": "AWS::Lambda::Url",
"Properties": {
"TargetFunction": {
"Ref": "NovaIdpAuthFunction"
},
"AuthType": "AWS_IAM"
}
},
"NovaIdpTokenVendUrl": {
"Type": "AWS::Lambda::Url",
"Properties": {
"TargetFunction": {
"Ref": "NovaIdpTokenVendFunction"
},
"AuthType": "AWS_IAM"
}
},
"NovaIdpJwksUrl": {
"Type": "AWS::Lambda::Url",
"Properties": {
"TargetFunction": {
"Ref": "NovaIdpJwksFunction"
},
"AuthType": "NONE"
}
}
}
}
```
+747
View File
@@ -0,0 +1,747 @@
# Operator Guide — Nova Platform Ops (`nova-platform-ops`)
> **REQ-OPS-GUIDE** — the operator-facing runbook for the
> `nova-platform-ops` Terraform repo. This is the verification surface
> for the covered-reference REQs (355-366, 371): their cutover gates
> (M1/M1.5/M2) are documented in §18 below, and each REQ has a
> **"Result" column** that the operator fills in after running the gate.
> P6 audit verifies every covered-reference REQ has a non-empty, green
> Result (grill CF-2/G-5). **HARD P6 ship gate:** §3 contains the
> operator-attested "M1.5 Verification Gate Result" row (grill
> CF-1/G-2.1) — the milestone does not ship until that row is filled.
>
> Audience: platform operators / SREs running the live Nova platform in
> AWS account `581513795199`. For the developer auth flows, see
> `docs/developer-guide-auth.md`; for the legacy CloudFormation path,
> see `docs/archive/nova-idp-cfn-v1.28.md`.
## 1. Overview + reposplit rationale
Nova's platform operations live in a dedicated, Gitea-private Terraform
repository — `nova-platform-ops` — separate from the engineering repo
`acdl/acdl`. The split is grounded in Vision §4 (Domain Boundaries):
> *The platform begins where the artifact is compiled and ends where it
> runs in production under operational guardrails.*
That is two distinct disciplines with two distinct ownership surfaces:
| Discipline | Ends | Begins | Repo | Surface |
|------------|------|--------|------|---------|
| Engineering | at the compiled artifact | — | `acdl/acdl` (GitHub) | `publish.yml` + GitHub Releases |
| Operations | — | at the live platform under guardrails | `nova-platform-ops` (Gitea-private) | Terraform modules |
**Scope split (CLARIFY Q-P1, D-232):**
- `acdl/acdl` authors `publish.yml` (the artifact publish pipeline) +
the artifacts themselves (Lambda zip, layer wheel, Python wheel, ECR
container image with the static `kj` binary). Each tag `v1.29.x`
produces a GitHub Release with SHA-256-verified artifacts (REQ-354).
- `nova-platform-ops` authors the Terraform modules
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
that bring those artifacts live in `581513795199`.
The handoff between the two repos is the **tag-pin** (D-235):
`nova-platform-ops` declares `local.nova_platform_version` +
`local.kj_source_sha` and resolves substrates through a single
`data.aws_ecr_image.kj_image`. The engineering repo never knows which
tag is live; the ops repo never authors artifacts. Vision §6
immutability + Vision §5 narrow interfaces.
The covered-reference REQs (355-366, 371) are authored in
`nova-platform-ops` (out-of-band). CIAgent in `acdl` tracks them for
milestone completeness; their verification surface is the cutover
gates in §18 of this guide.
## 2. Day-0 cutover procedure (M1)
The M1 cutover is the one-time conversion of the live AWS account
`581513795199` from CloudFormation-managed (or manually-created)
resources to Terraform-managed resources in `nova-platform-ops`. It is
conditional on the M1.5 verification gate passing (§3, Q7 carry-forward,
D-236).
The 10-step Journey 2 (spec §3.2):
1. **Create `nova-platform-ops` in Gitea** — private (`private: true`,
OPER-PRIV, REQ-359), no GitHub mirror. The repo is operator-owned;
CIAgent has no presence there.
2. **Commit the initial Terraform structure** — the module tree
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
+ `importable-resources.tf` (§12) + `versions.tf` + `backend.tf`
(S3 state in the imported bucket).
3. **`terraform init`** — initialize the S3 backend against the
state bucket (`nova-tfstate-581513795199-us-east-1`, imported in
step 5). The bucket is created manually once (operator's secure
scratch, spec §7.1, D-235) before Terraform adopts it.
4. **`terraform import` for existing live resources** — adopt the
resources that already exist in `581513795199` into Terraform state
without recreating them. The import map is in
`importable-resources.tf` (§12):
- `aws_s3_bucket.nova_tfstate``nova-tfstate-581513795199-us-east-1`
- `aws_dynamodb_table.nova_contracts``nova-contracts`
- `aws_dynamodb_table.nova_change_requests``nova-change-requests`
- `aws_dynamodb_table.nova_outbox``nova-outbox`
- `aws_iam_role.acdl_act_runner``acdl-act-runner-role`
- per-stack CMKs (KMS keys)
Re-import exits non-zero with `resource_already_imported`
(IMPORT-IDEMPOTENT, REQ-361). CI import treats this as idempotent
success — the import workflow greps the error stream + exits 0 on
that string.
5. **(state bucket is imported in step 4)** — listed here for sequence
clarity; the S3 state bucket is the first import because the
backend depends on it.
6. **Add new resources** that do not yet exist in the account:
- KMS alias `alias/nova-oidc-signing` (§9, D-234).
- Identity DynamoDB tables: `nova-users`, `nova-sessions`,
`nova-pats` (§11).
- JWKS Function URL with `AuthType: AWS_IAM` (NOT `NONE` — §10,
INV-18, RESEARCH §4 critical pitfall).
- CloudFront distribution + OAC + WAF WebACL + ACM certificate +
Route53 alias (§14, REQ-364/365/366).
7. **`terraform plan`** — expect zero diff on the imported resources
(they are already in their desired state) + a pure-add diff on the
new resources. If the plan shows a diff on an imported resource,
the import map or the Terraform resource block is wrong — fix
before apply. **DRIFT-DETECT (REQ-356):** `terraform plan` exit 2
(drift) fails the apply workflow; manual reconciliation required.
8. **HITL approval**`terraform apply` against `main` requires a
Gitea Actions approval from a user **distinct from the PR author**
(TFM-HITL, REQ-357, INV-3). Self-approval is rejected:
`gitea.triggering_actor == pull_request.user.login` → apply fails
closed (M1.5 item 11).
9. **`terraform apply`** — on approval, the apply creates the new
resources + adopts the imported ones. Smoke test (step 10) before
declaring M1 done.
10. **Smoke test + CFN→Terraform conversion** — verify the live
account is in the desired state (JWKS reachable via CloudFront,
KMS round-trip, ABAC fail-closed). The CFN template in
`acdl/acdl/nova/idp/setup.py` is archived to
`docs/archive/nova-idp-cfn-v1.28.md` as read-only reference
(REQ-369); the active path is now `terraform apply` in
`nova-platform-ops`.
## 3. M1.5 verification gate (12-item spike)
The M1.5 gate is the 12-item spike from PLAN.md "Happy Path" (spec
§3.3 Edge 5 items 1-8 + grill CF-1 items 9-12). **3 consecutive green
rebuilds are required** in `nova-platform-ops` CI.
The 12 items:
1. `kj` v0.0.3 (pinned SHA in `platform/abac/kj-version.txt`) compiles
with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64`.
2. Resulting binary reports `file kj → ELF 64-bit LSB executable,
x86-64, statically linked, no shared library` (KJ-STATIC).
3. Container image built from
`public.ecr.aws/lambda/python:3.12-al2023` with the binary copied
to `/opt/kj/kj`, `chmod 0555`, owned by `sbx_user:1051`.
4. Lambda runtime `python3.12` executes
`nova_idp_token_vend.handler`; the handler invokes
`subprocess.run(['/opt/kj/kj', 'apply', ...])` and parses stdout
JSON.
5. `tests/test_idp_auth.py` passes against the live image in moto-DDB.
6. `tests/test_kms_roundtrip.py` passes against the live KMS key
`alias/nova-oidc-signing` (REQ-362 path — covered-reference).
7. End-to-end: known PAT → known ABAC-allowed action → signed OIDC
token → `jose` verification → green. Known PAT + ABAC-denied action
→ 403 with deny reason logged (INV-17 fail-closed).
8. Image URI is recorded in Terraform state and in this operator
guide (§18, REQ-371 Result row).
9. **(grill CF-1) JWKS-EDGE-ONLY:** direct JWKS Function URL GET
(bypassing CloudFront) returns **403**; via-CloudFront GET returns
**200** (INV-18). Proves `AuthType: AWS_IAM` + OAC pinning.
10. **(grill CF-1) IAM-NARROW:** `aws iam get-role-policy` on the
Gitea OIDC role asserts no `Action: "*"` and no `Resource: "*"`
(REQ-360).
11. **(grill CF-1) TFM-HITL:** a `terraform apply`
`workflow_dispatch` triggered by the PR author is **rejected**
(exit non-zero); a dispatch by a distinct user proceeds (REQ-357).
12. **(grill CF-1) rollback drill:** revert `nova_platform_version`
pin → `terraform apply` → assert the prior ECR digest runs
(D-236, guards against ECR tag mutability).
If items 1-7 fail three consecutive rebuilds, M2a activates (§5,
REQ-363b Fargate toggle) with the same ECR image — no warmup hit
because the standby is always running the same digest (KJ-LOCKSTEP).
### HARD P6 ship gate (grill CF-1/G-2.1)
P6 must not ship `v1.28.6` until the operator attests the M1.5 result
in the row below. The operator fills this in **after** the gate passes
3 consecutive green rebuilds in `nova-platform-ops` CI. P6 audit
verifies the row exists + is non-empty.
#### M1.5 Verification Gate Result
| Rebuild # | Run ID / commit SHA | All 12 items green? | Attestor identity | Attested at (UTC) |
|-----------|---------------------|---------------------|-------------------|-------------------|
| 1 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
| 2 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
| 3 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
> **P6 audit rule:** all three rows must be present, all 12 items
> green on each, the three run IDs/SHAs distinct (consecutive
> rebuilds, not one run copied thrice), and the attestor identity
> non-empty. Empty or red → P6 blocks → escalate.
## 4. M2 operational handoff loop
M2 is the steady-state operational loop for rolling out an engineering
change after M1.5 is green. The loop is the tag-pin bump → plan →
HITL → apply cycle (D-235, D-238).
1. **Tag-pin bump** — the operator opens a PR in `nova-platform-ops`
bumping `local.nova_platform_version` (e.g. `v1.29.3` → `v1.29.4`)
+ `local.kj_source_sha` (the `kj` source SHA from
`platform/abac/kj-version.txt` at the new tag). Both pins move
together — there is one ECR image identity (KJ-LOCKSTEP, REQ-371).
2. **`terraform plan`** — CI runs `terraform plan` on the PR. The
KJ-LOCKSTEP precondition (a `lifecycle.precondition` on both
image-bearing resources — the Lambda `image_uri` and the Fargate
task `container_definitions[0].image`) checks that both
`image_uri` attributes resolve to the **same ECR digest** via
`data.aws_ecr_image.kj_image`. If the two diverge, the plan fails
closed — no second pipeline, no second SHA pin (D-238).
3. **HITL approval** — a Gitea Actions approver **distinct from the
PR author** approves the apply (TFM-HITL, REQ-357). Self-approval
is rejected (M1.5 item 11).
4. **`terraform apply`** — on approval, the apply updates both
`aws_lambda_function.nova_idp_token_vend.image_uri` and
`aws_ecs_task_definition.kj.container_definitions[0].image` to the
same ECR digest. The Lambda image + the Fargate task redeploy to
the same digest in one apply. Zero diff on KMS, DDB, IAM, edge
(the only change is the image reference).
**Verification:** after the apply, `aws lambda get-function
--function-name nova-idp-token-vend --query Configuration.Code.ImageUri`
and `aws ecs describe-tasks` on the Fargate task both report the same
digest. This is the M2 acceptance gate (PLAN §UX Acceptance Criteria
3) + the REQ-371 Result row in §18.
## 5. M2a Fargate activation (conditional)
M2a activates **only if M1.5 fails 3 consecutive rebuilds** (D-236).
It is the REQ-363b Fargate toggle — an always-warm minimal Fargate
standby running the **same ECR image** as the Lambda (KJ-LOCKSTEP).
Because the standby is always running the same digest as the Lambda,
activating M2a is **not** a warmup hit — the standby is already
serving `GET /health → 200` every 10s (KJ-WARMUP-HEALTH, §15). The
toggle repoints token-vend traffic from the Lambda to the Fargate
task; no cold start, no image pull.
If both the Lambda path and the Fargate path fail (M1.5 items 1-7
fail on both substrates), the operator escalates — Nova-idp ships in
read-only partial mode (no token issuance) until `kj` is verified
(Q7 carry-forward, spec §7.7).
**Fargate sunset discipline (D-237):** the standby (~$15-20/month,
§7) may not be deleted unless REQ-363 has been green in production
for **≥30 consecutive days**. Sunset requires an architecture review.
See §15 for the health-check procedure.
## 6. Rollback procedure (D-236)
Rollback is a tag-pin revert — the same mechanism as the M2 rollout
(§4), in reverse.
1. **Revert `nova_platform_version`** in `nova-platform-ops` to the
prior tag (e.g. `v1.29.4` → `v1.29.3`). Open a PR, get HITL
approval (TFM-HITL, same as rollout).
2. **`terraform apply`** — the apply reverts both the Lambda
`image_uri` and the Fargate task `image` to the prior ECR digest.
The prior tag's artifacts remain downloadable (GitHub Releases are
append-only per tag, REQ-354 AC 2) — no artifact is re-built.
3. **Verify** the prior digest is running:
```sh
aws lambda get-function --function-name nova-idp-token-vend \
--query Configuration.Code.ImageUri --output text
# → <account>.dkr.ecr.us-east-1.amazonaws.com/nova-kj@sha256:<prior-digest>
```
This is the M1.5 item 12 rollback drill + the operational rollback
procedure. It guards against ECR tag mutability (RESEARCH §2) — the
digest is immutable even if a tag is re-pushed.
## 7. Cost section
Monthly estimate for the `nova-platform-ops` live platform in account
`581513795199` (pilot volume):
| Resource | Quantity | Est. monthly | Notes |
|----------|----------|-------------|-------|
| WAF WebACL (CloudFront-scoped) | 1 | ~$5-10 | + per-request; REQ-365 |
| Fargate standby (0.25 vCPU, 512 MB) | 1 task | ~$15-20 | REQ-363b AC 4; largest line item |
| KMS asymmetric key | 1 | ~$1 | `alias/nova-oidc-signing`, ECC_NIST_P256 |
| DynamoDB (on-demand, 6 tables) | 6 | ~$2 | §11 tables |
| Lambda invocations (3 Lambdas) | 3 | ~$2 | low pilot volume |
| ECR image storage | ~100 MB | <$1 | the `kj` image |
| S3 state bucket + access logs | 1 | <$1 | `nova-tfstate-*` |
| CloudFront + ACM + Route53 | 1 distribution | ~$1 | ACM free for CloudFront-attached |
| **Total** | | **~$30-40/month** | |
**Fargate standby is the largest line item** (~$15-20/month, REQ-363b
AC 4). It is explicitly documented here with the D-237 sunset
discipline (§5, §15): ≥30 consecutive days green before deletion +
architecture review. Do not delete the standby to save ~$15/month
without that review — it is the defensive fallback for the `kj`
substrate.
## 8. Artifact-mirror fallback (Edge 6)
When the Gitea `act_runner` in `nova-platform-ops` CI cannot reach
GitHub Releases (network partition, egress restriction, GitHub
outage), the operator mirrors the artifact bundle locally by SHA-256.
**Procedure:**
1. **Download the GitHub Release bundle** for the target tag
(`v1.29.x`) from a machine that can reach GitHub Releases:
```sh
gh release download v1.29.0 \
--repo continuous-intelligence/acdl \
--pattern 'nova-lambda-token-vend-*.zip' \
--pattern 'nova-cli-layer-*.zip' \
--pattern 'nova-*-py3-none-any.whl' \
--dir ./artifact-cache
```
2. **Verify SHA-256** against the release body (each artifact's
SHA-256 is listed in the GitHub Release body, REQ-354):
```sh
sha256sum ./artifact-cache/nova-lambda-token-vend-v1.29.0.zip
# → must match the SHA-256 in the release body
```
3. **Place the bundle in the operator's local artifact cache** — a
directory the `act_runner` can read (e.g. a Gitea-lfs-tracked path
in `nova-platform-ops`, or an S3 bucket the runner can reach).
4. **Reference by SHA-256 in the terraform variables** — the
`nova-platform-ops` Terraform accepts an override for the artifact
source: `nova_artifact_mirror_sha256 = "<sha256>"`. When set, the
`data` sources resolve from the local cache by SHA-256 instead of
from GitHub Releases. Unset → resume GitHub Releases resolution.
This fallback is for CI continuity only; the live `terraform apply`
still resolves the ECR image by digest (KJ-LOCKSTEP), which is
independent of GitHub Releases availability.
## 9. KMS rotation (D-234)
The OIDC signing key `alias/nova-oidc-signing` is provisioned with
`KeySpec: ECC_NIST_P256`, `KeyUsage: SIGN_VERIFY`, on a **90-day
rotation cadence** (matches per-stack CMK rotation per D-069).
**Verify the key spec + rotation status:**
```sh
aws kms describe-key --key-id alias/nova-oidc-signing \
--query 'KeyMetadata.[KeySpec,KeyUsage,Description]' --output text
# → ECC_NIST_P256 SIGN_VERIFY <description>
```
**Apply a rotation policy** (key re-point, not key deletion — the
alias moves to a new key while the old key stays valid during the
overlap window, §17 JWKS-ROTATION):
1. Create the new key (same spec):
```sh
NEW_KEY=$(aws kms create-key \
--key-spec ECC_NIST_P256 \
--key-usage SIGN_VERIFY \
--description "nova-oidc-signing-$(date +%Y%m%d)" \
--query KeyId --output text)
```
2. Re-point the alias:
```sh
aws kms update-alias --alias-name alias/nova-oidc-signing \
--target-key-id "$NEW_KEY"
```
3. Apply the rotation policy (the key policy grants `kms:Sign` to the
token-vend Lambda role + `kms:GetPublicKey` to the JWKS Lambda
role):
```sh
aws kms put-key-policy --key-id "$NEW_KEY" \
--policy-name default --policy file://kms-signing-key-policy.json
```
4. After the 24-hour overlap window (§17), disable + schedule deletion
of the old key:
```sh
aws kms disable-key --key-id "<old-key-id>"
aws kms schedule-key-deletion --key-id "<old-key-id>" \
--pending-window-in-days 7
```
5. Verify the new key is active: `nova idp setup --verify` (the KMS
round-trip test, REQ-362).
**Audit:** emit a `kms.key_rotated` event with `old_key_id`,
`new_key_id`, `rotated_at`.
## 10. JWKS reachability via CloudFront edge (D-233, INV-18)
The JWKS endpoint is the **only public read surface** of the live
platform (INV-18, D-233). All other platform endpoints gate with
`AuthType: AWS_IAM`. CloudFront + OAC pinning replaces direct Lambda
Function URL exposure.
**Critical pitfall (RESEARCH §4):** the JWKS Function URL
`AuthType` MUST be `AWS_IAM`, NOT `NONE`. A common mistake is to set
`AuthType: NONE` on the Function URL (thinking CloudFront is the
gate) — that exposes the JWKS endpoint directly to the internet,
bypassing OAC. The correct configuration:
| Setting | Value |
|---------|-------|
| Function URL `AuthType` | `AWS_IAM` (NOT `NONE`) |
| CloudFront OAC `OriginAccessControlOriginType` | `lambda` |
| CloudFront OAC `SigningBehavior` | `always` |
| Lambda resource policy | `lambda:InvokeFunctionUrl` scoped to the CloudFront distribution ARN |
With `AuthType: AWS_IAM` + OAC `always` signing, CloudFront signs
every origin request with SigV4; a direct Function URL request has no
SigV4 signature → 403. Only CloudFront can reach the origin.
**Verification (M1.5 item 9):**
```sh
# Via CloudFront → 200
curl -sI https://<jwks-domain>/.well-known/jwks.json | head -1
# → HTTP/2 200
# Direct Function URL → 403
curl -sI "<raw-function-url>/.well-known/jwks.json" | head -1
# → HTTP/2 403
```
If the direct Function URL returns 200, the `AuthType` is `NONE` —
fix the Terraform + re-apply before declaring M1.5 green.
## 11. PITR restore (data-engineer section)
DynamoDB point-in-time recovery (PITR) is enabled on every identity +
contract table. PITR lets you restore a table to any second in the
last **35 days** (the AWS retention window).
**Tables with PITR enabled:**
| Table | Purpose |
|-------|---------|
| `nova-contracts` | contract ingestor records |
| `nova-change-requests` | change request ledger |
| `nova-outbox` | audit outbox |
| `nova-users` | Nova-idp users (Argon2id hashes) |
| `nova-sessions` | Nova-idp sessions (TTL `expires_at`) |
| `nova-pats` | Nova-idp PATs (revocation strong-read, D-229) |
**Enable PITR (on a new/restored table — PITR does not carry over
from the source):**
```sh
aws dynamodb update-continuous-backups \
--table-name <table> \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
```
**Restore a table to a point in time** (PITR never overwrites the
source — restore to a NEW table, then repoint):
```sh
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
aws dynamodb restore-table-to-point-in-time \
--source-table-name <table> \
--target-table-name <table>-restored \
--restore-date-time "$RESTORE_TO" \
--billing-mode-restore-as-is
# After the restore completes (status ACTIVE), repoint the app:
# - update the stack env var to the restored table name, or
# - rename: delete <table>, then update-table --new-table-name <table>
# Then re-enable PITR on the restored table (see above).
```
**Verify PITR is enabled on all tables:**
```sh
for t in nova-contracts nova-change-requests nova-outbox \
nova-users nova-sessions nova-pats; do
aws dynamodb describe-continuous-backups --table-name "$t" \
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' \
--output text
done
# → ENABLED (x6)
```
Restores older than 35 days are impossible — for longer retention,
export to S3 via the on-demand export or a scheduled AWS Backup plan.
## 12. DynamoDB import addresses (REQ-361, covered-reference)
The `importable-resources.tf` map in `nova-platform-ops` lists the
existing live resources that `terraform import` adopts at M1 cutover
(§2 step 4). Re-import exits non-zero with
`resource_already_imported` (IMPORT-IDEMPOTENT); CI import treats this
as idempotent success.
| Terraform address | AWS resource | Type |
|-------------------|--------------|------|
| `aws_s3_bucket.nova_tfstate` | `nova-tfstate-581513795199-us-east-1` | S3 bucket (state backend) |
| `aws_dynamodb_table.nova_contracts` | `nova-contracts` | DynamoDB table |
| `aws_dynamodb_table.nova_change_requests` | `nova-change-requests` | DynamoDB table |
| `aws_dynamodb_table.nova_outbox` | `nova-outbox` | DynamoDB table |
| `aws_iam_role.acdl_act_runner` | `acdl-act-runner-role` | IAM role (reused, spec §7.6) |
| `aws_kms_key.<per_stack_cmk>` | per-stack CMKs | KMS key (one per stack) |
The identity tables (`nova-users`, `nova-sessions`, `nova-pats`) are
**new** resources added at M1 (§2 step 6), not imported — they do
not yet exist in the account at M1.
## 13. PAT revocation (D-229)
PAT revocation has a **60s SLO**: the token-vend Lambda does a
strongly-consistent DynamoDB read (`ConsistentRead=True`) on every
token-vend request. A revoked PAT is reflected on the next vend,
within 60s P95.
**Verify a PAT's revocation status (strong read):**
```sh
aws dynamodb get-item \
--table-name nova-pats \
--key '{"jti":{"S":"<pat-id>"}}' \
--consistent-read \
--query 'Item.status.S' --output text
# → active (still valid)
# → revoked (next token-vend returns 403)
```
**Revoke a PAT at the DDB level** (emergency — when the CLI is
unavailable; the `jti` is known but the raw PAT is not):
```sh
aws dynamodb update-item \
--table-name nova-pats \
--key '{"jti":{"S":"<pat-id>"}}' \
--update-expression "SET #s = :r" \
--expression-attribute-names '{"#s":"status"}' \
--expression-attribute-values '{":r":{"S":"revoked"}}'
```
The item is **retained** (not deleted) so the audit trail is intact —
only `status` flips from `active` to `revoked`. The next `token-vend`
call with that `jti` returns `403 pat_revoked` immediately (D-229:
the strong read is synchronous).
## 14. Edge configuration (REQ-364/365/366, covered-reference)
The edge stack fronts the JWKS Lambda with CloudFront + WAF + ACM +
Route53. This is the public read surface (§10, INV-18).
### CloudFront + OAC (REQ-364)
- Distribution origin = the JWKS Lambda Function URL.
- OAC: `OriginAccessControlOriginType: lambda`,
`SigningBehavior: always` (§10).
- Cache behavior: `Cache-Control: max-age=3600` honored (JWKS-SLO).
### WAF WebACL (REQ-365)
- Scope: `CLOUDFRONT` (the WebACL is in `us-east-1`, the only region
for CloudFront-scoped WebACLs).
- Rate-based rule: `RateBasedStatement` with `Limit: 3000`,
`AggregateKeyType: IP`, `EvaluationWindowSec: 300` (3000 requests
per 5 minutes per IP).
- Managed rules: `AWSManagedRulesCommonRuleSet` (the AWS managed rule
group for common attacks).
### ACM certificate (REQ-366)
- Certificate in `us-east-1` (CloudFront requires the cert in
us-east-1).
- DNS validation (a CNAME record per validation record is written to
Route53). The cert status MUST be `ISSUED` (not
`PENDING_VALIDATION`) before the CloudFront distribution can serve
the domain.
### Route53 (REQ-366)
- An A-alias record pointing to the CloudFront distribution's domain
name.
### `route53_record_not_resolvable` debugging
If the JWKS domain does not resolve (`route53_record_not_resolvable`
or `NXDOMAIN`):
1. **Check ACM cert status:**
```sh
aws acm describe-certificate --certificate-arn <arn> \
--query 'Certificate.Status' --output text
# → must be ISSUED, not PENDING_VALIDATION
```
If `PENDING_VALIDATION`, the DNS validation CNAME records are not
in Route53 (or not propagated). Re-apply the validation records +
wait for AWS to validate (typically minutes).
2. **Check CloudFront status:**
```sh
aws cloudfront get-distribution --id <id> \
--query 'Distribution.Status' --output text
# → must be Deployed
```
If `InProgress`, wait for the deployment to finish. CloudFront
deployments take ~5-15 minutes.
3. **Check the Route53 alias record** points to the CloudFront
distribution domain name (not the Function URL).
## 15. Fargate standby health (KJ-WARMUP-HEALTH, REQ-363b)
The Fargate standby is the always-warm minimal defensive fallback
(REQ-363b). It runs the **same ECR image** as the Lambda (KJ-LOCKSTEP,
REQ-371) — so it is always running the current digest, never a stale
one.
**Health probe:** `GET /health → 200` every **10s**
(KJ-WARMUP-HEALTH).
**Failure handling:** 3 consecutive probe failures → alert + the
token-vend path **fails closed** (no signing). The standby does not
silently degrade — if it is not healthy, token-vend does not fall
back to it; it fails closed (INV-17 ABAC discipline extended to the
substrate).
**Verify the standby is `READY` before M1 cutover:**
```sh
# The Fargate task health check (target group)
aws elbv2 describe-target-health \
--target-group-arn <tg-arn> \
--query 'TargetHealthDescriptions[0].TargetHealth.State' --output text
# → healthy
# Direct probe
curl -sI https://<fargate-endpoint>/health | head -1
# → HTTP/1.1 200
```
**Fargate sunset discipline (D-237):** the standby may not be deleted
unless REQ-363 has been green in production for **≥30 consecutive
days**. Sunset requires an architecture review. Do not delete the
standby to save ~$15/month (§7) without that review — it is the
defensive fallback for the `kj` substrate.
## 16. IAM scope (IAM-NARROW, REQ-360, covered-reference)
The Gitea OIDC role for `act_runner` (reused `acdl-act-runner-role`,
spec §7.6) is bounded per REQ-360. **No `Action: "*"` or `Resource:
"*"`** (IAM-NARROW).
The scope covers only:
| Action | Scope | Why |
|--------|-------|-----|
| `kms:*` | customer-managed keys in `581513795199` | KMS signing + rotation |
| `dynamodb:*` | tables prefixed `nova-` | identity + contract tables |
| `lambda:*` | functions prefixed `nova-` | the 3 Nova-idp Lambdas |
| `s3:*` | buckets prefixed `nova-` | state bucket + artifact cache |
| `cloudfront:*` | tagged resources | the JWKS distribution |
| `wafv2:*` | tagged resources | the WebACL |
| `acm:*` | tagged resources | the JWKS cert |
| `route53:*` | tagged resources | the JWKS alias |
| `iam:PassRole` | roles tagged `nova-ops-only` | pass roles to Lambda/ECS only |
**Verify (M1.5 item 10):**
```sh
aws iam get-role-policy --role-name acdl-act-runner-role \
--policy-name <policy-name> --query 'PolicyDocument' --output json \
| jq '.Statement[].Action, .Statement[].Resource'
# → no "*" in either list
```
If `Action: "*"` or `Resource: "*"` appears, the IAM policy is too
broad — fix the Terraform + re-apply before declaring M1.5 green.
## 17. JWKS-ROTATION
On KMS key rotation (§9), **both old + new public keys** are
published in the JWKS during a **24-hour overlap window**. The old
key is removed from the JWKS only after consumers pick up the new
one.
- During the overlap: the JWKS Lambda lists all keys the alias has
pointed at that are still enabled. Already-issued OIDC tokens
(signed with the old key) keep verifying until they expire (OIDC
TTL default 15 min; PAT TTL ≤ 24h dev / ≤ 1h service-account).
- **Do not disable the old key until at least the max PAT TTL (24h)
has elapsed.**
- After the overlap, the old key is removed from the JWKS + disabled +
scheduled for deletion (§9 step 4).
This is JWKS-ROTATION (NFR) — the rotation is non-disruptive because
consumers cache the JWKS for up to `max-age=3600` (1h, JWKS-SLO) and
re-fetch within that window, picking up both keys during the overlap.
## 18. Cutover Gates (grill CF-2/G-5)
Each covered-reference REQ has a cutover gate (M1/M1.5/M2) with a
verification command + a **"Result" column**. The operator fills the
Result column after running the gate in `nova-platform-ops` CI.
**P6 audit verifies every covered-reference REQ has a non-empty,
green Result.** Empty or red → P6 blocks (grill CF-2/G-5).
| REQ | Gate | Verification command | Result |
|-----|------|----------------------|--------|
| REQ-355 | M1 | `terraform plan` resolves `data.aws_ecr_image.kj_image` from `local.nova_platform_version` + `local.kj_source_sha`; both image_uri attributes present | _(operator fills: green/red + run ID/SHA + attestor)_ |
| REQ-356 | M1 | `terraform plan` exit 0 (no drift) on a clean checkout of `main`; exit 2 → `drift_detected` fails the apply workflow | _(operator fills)_ |
| REQ-357 | M1.5 | `terraform apply` `workflow_dispatch` triggered by PR author → rejected; distinct user → proceeds (M1.5 item 11) | _(operator fills)_ |
| REQ-358 | M2 | bump `nova_platform_version` → `terraform apply` → `aws lambda get-function ... ImageUri` `CodeSha256` matches the artifact SHA-256 from the GitHub Release body | _(operator fills)_ |
| REQ-359 | M1 | `git -C nova-platform-ops remote -v` shows only the Gitea private remote (no GitHub mirror); Gitea repo `private: true` | _(operator fills)_ |
| REQ-360 | M1.5 | `aws iam get-role-policy` on the OIDC role asserts no `Action: "*"` + no `Resource: "*"` (M1.5 item 10, §16) | _(operator fills)_ |
| REQ-361 | M1 | `terraform import` on each address in `importable-resources.tf` (§12) succeeds; re-import exits `resource_already_imported` → CI treats as idempotent success (IMPORT-IDEMPOTENT) | _(operator fills)_ |
| REQ-362 | M1.5 | `nova idp setup --verify` (KMS round-trip) against `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) → `{"passed":true}` (M1.5 item 6) | _(operator fills)_ |
| REQ-363 | M1.5 | `nova_idp_token_vend.handler` invokes `subprocess.run(['/opt/kj/kj','apply',...])` on the live image; `file(1)` reports `statically linked` (M1.5 items 2-4, KJ-STATIC) | _(operator fills)_ |
| REQ-363b | M1.5 | Fargate standby `GET /health → 200` every 10s (KJ-WARMUP-HEALTH); same ECR digest as the Lambda (KJ-LOCKSTEP); activates only if M1.5 items 1-7 fail 3× (§5) | _(operator fills)_ |
| REQ-364 | M1.5 | direct JWKS Function URL → 403; via-CloudFront → 200 (M1.5 item 9, §10, INV-18) | _(operator fills)_ |
| REQ-365 | M1 | `aws wafv2 get-web-acl` shows `RateBasedStatement` Limit 3000, AggregateKeyType IP, EvaluationWindowSec 300 + `AWSManagedRulesCommonRuleSet`; Scope CLOUDFRONT in us-east-1 (§14) | _(operator fills)_ |
| REQ-366 | M1 | `aws acm describe-certificate` Status `ISSUED`; Route53 A-alias resolves to the CloudFront distribution domain (§14) | _(operator fills)_ |
| REQ-371 | M2 | after `terraform apply`, both `aws_lambda_function.nova_idp_token_vend.image_uri` and `aws_ecs_task_definition.kj.container_definitions[0].image` report the same ECR digest (KJ-LOCKSTEP precondition green at plan) | _(operator fills)_ |
> **P6 audit rule (grill CF-2/G-5):** every row's Result column must
> be non-empty + green. An empty or red Result blocks the milestone
> ship. The operator attestation is the acdl-side evidence surface;
> the live verification runs in `nova-platform-ops` CI.
---
## Appendix — quick reference
| Procedure | Cadence / trigger | Section |
|-----------|-------------------|---------|
| Day-0 cutover (M1) | one-time | §2 |
| M1.5 verification gate | one-time (3 consecutive green rebuilds) | §3 |
| M2 operational handoff | per engineering change (tag-pin bump) | §4 |
| M2a Fargate activation | conditional (M1.5 fails 3×) | §5 |
| Rollback | on regression | §6 |
| Artifact-mirror fallback | on GitHub Releases unreachable | §8 |
| KMS rotation | every 90 days | §9 |
| JWKS-ROTATION overlap | on each KMS rotation (24h window) | §17 |
| PITR restore | on data loss / corruption (35-day window) | §11 |
| Emergency PAT revocation | on compromise (DDB-level, immediate) | §13 |
| Fargate standby health check | continuous (every 10s) | §15 |
| Fargate sunset | ≥30 consecutive days green + architecture review | §5, §15 |
| `route53_record_not_resolvable` debug | on JWKS domain not resolving | §14 |
| Cutover gate attestation | at M1/M1.5/M2 (operator fills Result column) | §18 |
+8 -3
View File
@@ -1,9 +1,10 @@
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, C-2.1, ≤50 lines).""" """nova idp setup --check/--apply/--verify (REQ-340, REQ-341, REQ-369, ≤50 lines)."""
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import json import json
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
@@ -19,8 +20,8 @@ def _load_setup():
def add_parser(subparsers): def add_parser(subparsers):
p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack") p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack")
p.add_argument("--check", action="store_true", help="check prerequisites") p.add_argument("--check", action="store_true", help="check prerequisites")
p.add_argument("--apply", action="store_true", help="generate + deploy (NFR-10 y/N)") p.add_argument("--apply", action="store_true", help="terraform apply (REQ-369; CFN fallback)")
p.add_argument("--verify", action="store_true", help="run the KMS round-trip test") p.add_argument("--verify", action="store_true", help="terraform plan (REQ-369; KMS fallback)")
p.add_argument("--dry-run", action="store_true", help="resource summary only") p.add_argument("--dry-run", action="store_true", help="resource summary only")
p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain") p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain")
p.set_defaults(_run=run) p.set_defaults(_run=run)
@@ -31,8 +32,12 @@ def run(args) -> int:
if args.check: if args.check:
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0 print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
if args.verify: if args.verify:
if shutil.which("terraform"):
r = mod.terraform_plan(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1 r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
if args.apply or args.dry_run: if args.apply or args.dry_run:
if not args.dry_run and shutil.which("terraform"):
r = mod.terraform_apply(); print(json.dumps(r, indent=2)); return 0 if r["deployed"] else 1
r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run) r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run)
print(json.dumps(r["summary"], indent=2)) print(json.dumps(r["summary"], indent=2))
return 0 if (r["deployed"] or args.dry_run) else 1 return 0 if (r["deployed"] or args.dry_run) else 1
+217
View File
@@ -0,0 +1,217 @@
"""nova idp setup terraform-delegation tests (REQ-369, spec §7.5).
P3 Wave 2: verifies the ``nova idp setup --apply`` / ``--verify`` paths
delegate to ``terraform apply -auto-approve`` / ``terraform plan`` when
``terraform`` is on PATH, and fall back to the archived CFN path
(emitting a ``DeprecationWarning``) when terraform is absent.
Mirrors the importlib loading + ``mock.patch``/``monkeypatch`` style of
``tests/test_idp_setup.py`` (``lambda`` is a Python reserved word).
"""
from __future__ import annotations
import importlib.util
import sys
import warnings
from pathlib import Path
from unittest import mock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
def _load(mod_name, rel_path):
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
setup = _load("nova_idp_setup_tf_test", _SETUP_PATH)
# ---------------------------------------------------------------------------
# core/lambda/nova_idp_setup.py — terraform_apply / terraform_plan
# ---------------------------------------------------------------------------
class TestTerraformApply:
def test_apply_invokes_terraform_apply_auto_approve(self, monkeypatch):
"""terraform_apply shells out to ``terraform apply -auto-approve``."""
called = {}
def _fake_run(cmd, **kw):
called["cmd"] = list(cmd)
return mock.MagicMock(returncode=0)
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
r = setup.terraform_apply()
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
assert r["deployed"] is True
assert r["returncode"] == 0
assert r["command"] == ["terraform", "apply", "-auto-approve"]
def test_apply_auto_approve_false_omits_flag(self, monkeypatch):
called = {}
def _fake_run(cmd, **kw):
called["cmd"] = list(cmd)
return mock.MagicMock(returncode=0)
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
setup.terraform_apply(auto_approve=False)
assert called["cmd"] == ["terraform", "apply"]
def test_apply_nonzero_returncode_means_not_deployed(self, monkeypatch):
monkeypatch.setattr(
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=1)
)
r = setup.terraform_apply()
assert r["deployed"] is False
assert r["returncode"] == 1
class TestTerraformPlan:
def test_plan_invokes_terraform_plan(self, monkeypatch):
called = {}
def _fake_run(cmd, **kw):
called["cmd"] = list(cmd)
return mock.MagicMock(returncode=0)
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
r = setup.terraform_plan()
assert called["cmd"] == ["terraform", "plan"]
assert r["passed"] is True
assert r["command"] == ["terraform", "plan"]
def test_plan_nonzero_returncode_means_not_passed(self, monkeypatch):
monkeypatch.setattr(
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=2)
)
r = setup.terraform_plan()
assert r["passed"] is False
assert r["returncode"] == 2
# ---------------------------------------------------------------------------
# generate_and_deploy emits DeprecationWarning (CFN fallback path)
# ---------------------------------------------------------------------------
class TestCfnFallbackDeprecation:
def test_generate_and_deploy_warns_on_cfn_path(self):
"""The archived CFN deploy path raises DeprecationWarning (REQ-369)."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with mock.patch("subprocess.check_call", return_value=0):
r = setup.generate_and_deploy(approve_fn=lambda: True)
assert r["deployed"] is True
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert len(dep) == 1, f"expected one DeprecationWarning, got {dep}"
assert "CFN path is archived" in str(dep[0].message)
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
def test_generate_and_deploy_dry_run_does_not_warn(self):
"""--dry-run is read-only inspection; it must not warn."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
r = setup.generate_and_deploy(dry_run=True)
assert r["deployed"] is False
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert dep == [], f"dry-run must not emit DeprecationWarning, got {dep}"
# ---------------------------------------------------------------------------
# nova/idp/setup.py CLI wrapper — terraform delegation vs CFN fallback
# ---------------------------------------------------------------------------
def _cli_args(**kw):
"""Build a MagicMock mimicking the argparse Namespace for `nova idp setup`."""
a = mock.MagicMock()
a.check = kw.get("check", False)
a.apply = kw.get("apply", False)
a.verify = kw.get("verify", False)
a.dry_run = kw.get("dry_run", False)
a.public_jwks_domain = kw.get("public_jwks_domain", None)
return a
class TestCliApplyDelegation:
def test_apply_delegates_to_terraform_when_on_path(self, monkeypatch, capsys):
"""terraform on PATH → --apply runs `terraform apply -auto-approve`."""
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
called = {}
def _fake_run(cmd, **kw):
called["cmd"] = list(cmd)
return mock.MagicMock(returncode=0)
from nova.idp import setup as cli_setup
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
# Patch subprocess.run inside the loaded core module (used by terraform_apply).
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
rc = cli_setup.run(_cli_args(apply=True))
assert rc == 0
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
out = capsys.readouterr().out
assert "deployed" in out
def test_apply_falls_back_to_cfn_when_terraform_absent(self, monkeypatch, capsys):
"""terraform absent → --apply falls back to the CFN path + warns."""
monkeypatch.setattr("shutil.which", lambda name: None)
from nova.idp import setup as cli_setup
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
# Stub the CFN deploy so it succeeds without touching aws CLI; answer
# the NFR-10 y/N prompt (the CLI path has no approve_fn hook).
monkeypatch.setattr("subprocess.check_call", return_value=0)
monkeypatch.setattr("builtins.input", lambda *a, **kw: "y")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
rc = cli_setup.run(_cli_args(apply=True))
assert rc == 0
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert len(dep) == 1, f"expected DeprecationWarning on CFN fallback, got {dep}"
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
out = capsys.readouterr().out
assert "AWS::Lambda::Function" in out # CFN resource summary printed
class TestCliVerifyDelegation:
def test_verify_delegates_to_terraform_plan_when_on_path(self, monkeypatch, capsys):
"""terraform on PATH → --verify runs `terraform plan`."""
from nova.idp import setup as cli_setup
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
called = {}
def _fake_run(cmd, **kw):
called["cmd"] = list(cmd)
return mock.MagicMock(returncode=0)
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
rc = cli_setup.run(_cli_args(verify=True))
assert rc == 0
assert called["cmd"] == ["terraform", "plan"]
out = capsys.readouterr().out
assert "passed" in out
def test_verify_falls_back_to_kms_roundtrip_when_terraform_absent(self, monkeypatch, capsys):
"""terraform absent → --verify falls back to the existing KMS round-trip."""
from nova.idp import setup as cli_setup
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
# The CLI loads core/lambda/nova_idp_setup.py into its own module
# instance; stub _load_setup so verify() is deterministic and does
# not require pyjwt/cryptography (the real round-trip is covered by
# tests/test_idp_setup.py).
fake_mod = mock.MagicMock()
fake_mod.verify.return_value = {"passed": True, "detail": "KMS round-trip OK"}
monkeypatch.setattr(cli_setup, "_load_setup", lambda: fake_mod)
rc = cli_setup.run(_cli_args(verify=True))
assert rc == 0
fake_mod.verify.assert_called_once()
out = capsys.readouterr().out
assert "passed" in out # KMS round-trip result printed
+108
View File
@@ -0,0 +1,108 @@
"""v1.29 consumer smoke test — sign-up → sign-in → token-vend → apply → audit (REQ-CONSUMER-BUMP).
Tests the pilot consumer (nova-blockchain-exchange) deploy chain against
the v1.29 publish artifacts. The consumer's deploy.yml is bumped from
@v1.25 → @v1.29 (Edge 8 / REQ-354 footnote). The smoke test verifies
the full chain: sign-up → sign-in → token-vend → apply → audit, using
the existing CAP-025 round-trip assertion (v1.26).
This test runs in two modes:
- acdl CI (no live AWS, no consumer repo): skips with a clear reason.
- nova-platform-ops CI / consumer CI: runs the full chain against
the v1.29.0 intermediate tag artifacts (produced by P1, grill CF-3).
The v1.29.0 tag triggers publish.yml to produce:
- nova-lambda-token-vend-v1.29.0.zip
- nova-cli-layer-v1.29.0.zip
- nova-1.29.0-py3-none-any.whl
- ECR image v1.29.0-kj-<sha>
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
_CONSUMER_REPO = os.environ.get("NOVA_CONSUMER_REPO", "")
_V129_ARTIFACTS_AVAILABLE = os.environ.get("NOVA_V129_ARTIFACTS", "") != ""
_SKIP_REASON = (
"v1.29 smoke test requires: (1) consumer repo checkout at "
"NOVA_CONSUMER_REPO, (2) v1.29.0 tag artifacts available "
"(set NOVA_V129_ARTIFACTS=1). Run in nova-platform-ops CI or "
"consumer CI with the v1.29.0 intermediate tag pushed."
)
@pytest.fixture
def consumer_repo():
if not _CONSUMER_REPO:
pytest.skip(_SKIP_REASON)
repo = Path(_CONSUMER_REPO)
if not repo.is_dir():
pytest.skip(f"consumer repo not found at {repo}")
return repo
def _deploy_uses_v129(repo: Path) -> bool:
for rel in (".github/workflows/deploy.yml", ".gitea/workflows/deploy.yml"):
p = repo / rel
if not p.exists():
continue
text = p.read_text()
if "@v1.25" in text:
return False
if "@v1.29" not in text:
return False
return True
class TestConsumerDeployBump:
"""REQ-CONSUMER-BUMP — consumer deploy.yml @v1.25 → @v1.29."""
def test_deploy_yml_references_v129(self, consumer_repo):
assert _deploy_uses_v129(consumer_repo), (
"consumer deploy.yml must reference @v1.29 (not @v1.25)"
)
def test_deploy_yml_inputs_correct(self, consumer_repo):
for rel in (".github/workflows/deploy.yml", ".gitea/workflows/deploy.yml"):
p = consumer_repo / rel
if not p.exists():
continue
text = p.read_text()
assert "mode: full" in text or "mode: 'full'" in text, (
f"{rel} must use mode: full"
)
assert "contract.yaml" in text, f"{rel} must reference contract.yaml"
@pytest.mark.skipif(not _V129_ARTIFACTS_AVAILABLE, reason=_SKIP_REASON)
class TestV129SmokeChain:
"""Sign-up → sign-in → token-vend → apply → audit against v1.29 artifacts.
Uses the CAP-025 round-trip assertion (v1.26): contract resolve →
adapter compile → terraform plan → policy scan → confidence signal →
attestation → outbox record against 581513795199.
"""
def test_signup_signin_token_vend_apply_audit(self, consumer_repo):
if not shutil.which("nova"):
pytest.skip("nova CLI not on PATH")
result = subprocess.run(
["nova", "apply", "--contract", str(consumer_repo / "contract.yaml"),
"--mode", "full", "--environment", "dev"],
capture_output=True, text=True, timeout=300,
)
assert result.returncode == 0, (
f"nova apply failed: {result.stderr}"
)
assert "attestation" in result.stdout.lower() or "applied" in result.stdout.lower()
def test_v129_smoke_test_exists():
"""Meta-test: verify this test file exists + is discoverable."""
assert Path(__file__).exists()