---ci--- project: acdl phase: 4 milestone: v1.29 status: complete ---/ci---
33 KiB
Operator Guide — Nova Platform Ops (nova-platform-ops)
REQ-OPS-GUIDE — the operator-facing runbook for the
nova-platform-opsTerraform 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, seedocs/developer-guide-auth.md; for the legacy CloudFormation path, seedocs/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/acdlauthorspublish.yml(the artifact publish pipeline) + the artifacts themselves (Lambda zip, layer wheel, Python wheel, ECR container image with the statickjbinary). Each tagv1.29.xproduces a GitHub Release with SHA-256-verified artifacts (REQ-354).nova-platform-opsauthors the Terraform modules (networking/kms/identity/contract-ingest/bootstrap/edge) that bring those artifacts live in581513795199.
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):
-
Create
nova-platform-opsin Gitea — private (private: true, OPER-PRIV, REQ-359), no GitHub mirror. The repo is operator-owned; CIAgent has no presence there. -
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).
-
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. -
terraform importfor existing live resources — adopt the resources that already exist in581513795199into Terraform state without recreating them. The import map is inimportable-resources.tf(§12):aws_s3_bucket.nova_tfstate←nova-tfstate-581513795199-us-east-1aws_dynamodb_table.nova_contracts←nova-contractsaws_dynamodb_table.nova_change_requests←nova-change-requestsaws_dynamodb_table.nova_outbox←nova-outboxaws_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. -
(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.
-
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(NOTNONE— §10, INV-18, RESEARCH §4 critical pitfall). - CloudFront distribution + OAC + WAF WebACL + ACM certificate + Route53 alias (§14, REQ-364/365/366).
- KMS alias
-
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 planexit 2 (drift) fails the apply workflow; manual reconciliation required. -
HITL approval —
terraform applyagainstmainrequires 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). -
terraform apply— on approval, the apply creates the new resources + adopts the imported ones. Smoke test (step 10) before declaring M1 done. -
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.pyis archived todocs/archive/nova-idp-cfn-v1.28.mdas read-only reference (REQ-369); the active path is nowterraform applyinnova-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:
kjv0.0.3 (pinned SHA inplatform/abac/kj-version.txt) compiles withCGO_ENABLED=0 GOOS=linux GOARCH=amd64.- Resulting binary reports
file kj → ELF 64-bit LSB executable, x86-64, statically linked, no shared library(KJ-STATIC). - Container image built from
public.ecr.aws/lambda/python:3.12-al2023with the binary copied to/opt/kj/kj,chmod 0555, owned bysbx_user:1051. - Lambda runtime
python3.12executesnova_idp_token_vend.handler; the handler invokessubprocess.run(['/opt/kj/kj', 'apply', ...])and parses stdout JSON. tests/test_idp_auth.pypasses against the live image in moto-DDB.tests/test_kms_roundtrip.pypasses against the live KMS keyalias/nova-oidc-signing(REQ-362 path — covered-reference).- End-to-end: known PAT → known ABAC-allowed action → signed OIDC
token →
joseverification → green. Known PAT + ABAC-denied action → 403 with deny reason logged (INV-17 fail-closed). - Image URI is recorded in Terraform state and in this operator guide (§18, REQ-371 Result row).
- (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. - (grill CF-1) IAM-NARROW:
aws iam get-role-policyon the Gitea OIDC role asserts noAction: "*"and noResource: "*"(REQ-360). - (grill CF-1) TFM-HITL: a
terraform applyworkflow_dispatchtriggered by the PR author is rejected (exit non-zero); a dispatch by a distinct user proceeds (REQ-357). - (grill CF-1) rollback drill: revert
nova_platform_versionpin →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).
-
Tag-pin bump — the operator opens a PR in
nova-platform-opsbumpinglocal.nova_platform_version(e.g.v1.29.3→v1.29.4)local.kj_source_sha(thekjsource SHA fromplatform/abac/kj-version.txtat the new tag). Both pins move together — there is one ECR image identity (KJ-LOCKSTEP, REQ-371).
-
terraform plan— CI runsterraform planon the PR. The KJ-LOCKSTEP precondition (alifecycle.preconditionon both image-bearing resources — the Lambdaimage_uriand the Fargate taskcontainer_definitions[0].image) checks that bothimage_uriattributes resolve to the same ECR digest viadata.aws_ecr_image.kj_image. If the two diverge, the plan fails closed — no second pipeline, no second SHA pin (D-238). -
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).
-
terraform apply— on approval, the apply updates bothaws_lambda_function.nova_idp_token_vend.image_uriandaws_ecs_task_definition.kj.container_definitions[0].imageto 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.
-
Revert
nova_platform_versioninnova-platform-opsto the prior tag (e.g.v1.29.4→v1.29.3). Open a PR, get HITL approval (TFM-HITL, same as rollout). -
terraform apply— the apply reverts both the Lambdaimage_uriand the Fargate taskimageto 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. -
Verify the prior digest is running:
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:
-
Download the GitHub Release bundle for the target tag (
v1.29.x) from a machine that can reach GitHub Releases: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 -
Verify SHA-256 against the release body (each artifact's SHA-256 is listed in the GitHub Release body, REQ-354):
sha256sum ./artifact-cache/nova-lambda-token-vend-v1.29.0.zip # → must match the SHA-256 in the release body -
Place the bundle in the operator's local artifact cache — a directory the
act_runnercan read (e.g. a Gitea-lfs-tracked path innova-platform-ops, or an S3 bucket the runner can reach). -
Reference by SHA-256 in the terraform variables — the
nova-platform-opsTerraform accepts an override for the artifact source:nova_artifact_mirror_sha256 = "<sha256>". When set, thedatasources 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:
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):
-
Create the new key (same spec):
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) -
Re-point the alias:
aws kms update-alias --alias-name alias/nova-oidc-signing \ --target-key-id "$NEW_KEY" -
Apply the rotation policy (the key policy grants
kms:Signto the token-vend Lambda role +kms:GetPublicKeyto the JWKS Lambda role):aws kms put-key-policy --key-id "$NEW_KEY" \ --policy-name default --policy file://kms-signing-key-policy.json -
After the 24-hour overlap window (§17), disable + schedule deletion of the old key:
aws kms disable-key --key-id "<old-key-id>" aws kms schedule-key-deletion --key-id "<old-key-id>" \ --pending-window-in-days 7 -
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):
# 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):
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):
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:
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):
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):
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=3600honored (JWKS-SLO).
WAF WebACL (REQ-365)
- Scope:
CLOUDFRONT(the WebACL is inus-east-1, the only region for CloudFront-scoped WebACLs). - Rate-based rule:
RateBasedStatementwithLimit: 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(notPENDING_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):
-
Check ACM cert status:
aws acm describe-certificate --certificate-arn <arn> \ --query 'Certificate.Status' --output text # → must be ISSUED, not PENDING_VALIDATIONIf
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). -
Check CloudFront status:
aws cloudfront get-distribution --id <id> \ --query 'Distribution.Status' --output text # → must be DeployedIf
InProgress, wait for the deployment to finish. CloudFront deployments take ~5-15 minutes. -
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:
# 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):
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-opsCI.
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 |