Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ba9981743 | |||
| 48657eb816 | |||
| e560adacb6 | |||
| 18b403fcd8 | |||
| 6aac523da7 | |||
| 65bf3d84e9 | |||
| adf3b1c02f | |||
| f8677fa002 | |||
| 713ad2eff2 | |||
| 9dc56698fb | |||
| 932923ee99 | |||
| 184f33c60a | |||
| a6510e7afc | |||
| c0cb1887ed | |||
| 139cb5077a | |||
| adc55a17ab | |||
| 0d8913a299 | |||
| 4697692ce7 | |||
| 23b8ff81d3 | |||
| d0a8c363b2 | |||
| 04053df16e | |||
| bcbeb7badb | |||
| 1f4f7f0f81 | |||
| df2b83c86b | |||
| f68349d94d | |||
| 1863a85144 | |||
| 7dab9d5756 | |||
| 14809327fb | |||
| 0662ed26a3 | |||
| cd3418a75e | |||
| dee6d88d87 | |||
| fe0ee6aa45 | |||
| 701cc572ce | |||
| 0736924de2 | |||
| 05bf8bf221 | |||
| 7a7fbfed82 | |||
| d06535032c | |||
| 8550ede810 | |||
| 71562d9db2 | |||
| 91cb931bab | |||
| a8ef1e8864 | |||
| 291921a04e | |||
| c9bfc98713 | |||
| ab069db3a4 | |||
| 3338ec1622 | |||
| eb4fade710 | |||
| 5dd7222571 | |||
| 5763e85bb7 | |||
| 37f462783f | |||
| cba7c1c189 | |||
| fd3f9e17b9 | |||
| 03adaa80a6 | |||
| 3a09ca8ec1 | |||
| d7971023b6 | |||
| 6a8267e13f | |||
| 2ed2b3ae0f | |||
| 83883076ff | |||
| 0388751c6e | |||
| 5d1a5f83da | |||
| e7af683af6 | |||
| 939a39743d | |||
| 88e2389a95 | |||
| a0c363c063 | |||
| bbfcbcc4d3 | |||
| e1dc59ba79 | |||
| c629809d75 | |||
| 05efb014d6 | |||
| 9ee1cc8925 | |||
| 48a769ced0 | |||
| 45423c33ae | |||
| 1faf4b560f | |||
| 8f62cfdbe7 | |||
| f28aed2f55 | |||
| 6b410d9ab4 | |||
| d019a1c4c4 | |||
| 2b2423532b | |||
| f2b481716d | |||
| 135359ebb8 | |||
| ecc9730f24 | |||
| 4fe1a1508e | |||
| a6b908c035 | |||
| e9fbb44ad1 | |||
| 155963d40d | |||
| e1b5dc2d1f | |||
| c0453817ad | |||
| f06a4c55b4 | |||
| cbdb2e2b9a | |||
| 7e7a4fa853 | |||
| 3a32c3b898 | |||
| f266dcf0fc | |||
| 6eb7af2ca0 | |||
| 074ee05f83 | |||
| a0799f13e5 | |||
| 6ced8eda7d | |||
| cec34abc22 | |||
| 6b60c0cbe3 | |||
| 268f695866 | |||
| 732998b01f | |||
| 5d1a9853ea | |||
| 03edd82d53 | |||
| 9bac2685cb | |||
| b237b3e85b | |||
| 023cc47025 | |||
| 3300ed2557 | |||
| e22661ab54 | |||
| 51b886f3f6 | |||
| 804c52aa90 | |||
| 373533094b | |||
| 59d837f6e7 | |||
| a63c85bc51 | |||
| 6a3d47e482 | |||
| 1d71b83197 | |||
| 3a43205c48 | |||
| 9f94103c57 | |||
| d022ddcea6 | |||
| 78da051b60 | |||
| ddf88202fc | |||
| 2ee541f40e | |||
| d391cdf0f7 | |||
| cf8aa53c8d | |||
| 270b1f11a3 | |||
| 2a4d7b7625 | |||
| 707d8a1e39 | |||
| 50e77e6314 | |||
| a0a658bc9a |
+305
-526
@@ -1,16 +1,28 @@
|
|||||||
# Nova — Architecture (v1.1 target)
|
# Nova — Architecture
|
||||||
|
|
||||||
> Target architecture for the real Agentic Cloud Delivery Platform (rebranded
|
> **Compressed.** The full v1.0–v1.24 architecture history (v1.1 spike
|
||||||
> Nova in v1.15). Source of truth for **how**: `docs/architecture.md` (v0.2) is the upstream
|
> scope, v1.2 build-out, v1.8–v1.16 addenda) is preserved verbatim at
|
||||||
> draft; this file is the Nova-repo operating copy, refined at phase
|
> `.ciagent/archive/ARCHITECTURE-v1.0-v1.24.md`. This file retains the
|
||||||
> boundaries. Where this file and `docs/vision.md` conflict, the vision wins.
|
> durable target architecture (§1–§12, the four layers + six cross-cutting
|
||||||
|
> concerns) + the three addenda that describe the **current state**:
|
||||||
|
> v1.11 (stateless adapter), v1.15 (Nova rebrand — current naming), and
|
||||||
|
> v1.17 (telemetry/observability layer + §12.7 Policy Engine Registry).
|
||||||
|
> Intermediate addenda (v1.1 spike scope, v1.2 build-out, v1.8/1.9/1.10/
|
||||||
|
> 1.12/1.13/1.14/1.16) describe evolved or superseded states and are
|
||||||
|
> preserved in the archive snapshot.
|
||||||
|
>
|
||||||
|
> Source of truth for **how**: `docs/architecture.md` (v0.2) is the
|
||||||
|
> upstream draft; this file is the Nova-repo operating copy, refined at
|
||||||
|
> phase boundaries. Where this file and `docs/vision.md` conflict, the
|
||||||
|
> vision wins.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Architecture is at **v0.2** upstream (`docs/architecture.md`). Milestone v1.1
|
Architecture is at **v0.2** upstream (`docs/architecture.md`). Milestone
|
||||||
**finalizes it to v1.0** in Phase 07 by resolving the 11 open decisions
|
v1.1 **finalized it to v1.0** in Phase 07 by resolving the 11 open
|
||||||
(see `PROJECT.md` open-decision resolutions table). This file records the
|
decisions (see `PROJECT.md` open-decision resolutions table). The v1.11
|
||||||
locked commitments and the v1.1 spike scope.
|
addendum (stateless adapter) and the v1.17 addendum (telemetry layer +
|
||||||
|
§12.7 Policy Engine Registry) record the current-state refinements.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -55,8 +67,9 @@ the same policy envelope, and the same evidence stream.
|
|||||||
### Layer 1 — Foundational Primitives
|
### Layer 1 — Foundational Primitives
|
||||||
Single-purpose, **engine-agnostic** primitive modules. L1 modules do
|
Single-purpose, **engine-agnostic** primitive modules. L1 modules do
|
||||||
not compose with other L1s; L1 takes its environment as input. The L1
|
not compose with other L1s; L1 takes its environment as input. The L1
|
||||||
interface is defined against the **Target Stack IR**, not against Terraform
|
interface is defined against the **Target Stack IR**, not against
|
||||||
directly (the IR is shaped to round-trip to Terraform in v1, per §12.1).
|
Terraform directly (the IR is shaped to round-trip to Terraform in v1,
|
||||||
|
per §12.1).
|
||||||
|
|
||||||
- No inter-L1 references. L1 may call Terraform data sources.
|
- No inter-L1 references. L1 may call Terraform data sources.
|
||||||
- Semver: interface → MAJOR, behavior → MINOR, lifecycle → PATCH (W3.D).
|
- Semver: interface → MAJOR, behavior → MINOR, lifecycle → PATCH (W3.D).
|
||||||
@@ -68,8 +81,8 @@ Combine L1 primitives into deployable shapes. Each codebase maps to one
|
|||||||
canonical L2 stack (`multiStack: true` only per W1.B). Shape X
|
canonical L2 stack (`multiStack: true` only per W1.B). Shape X
|
||||||
(parameterized module) or Shape Y (thin-composition layer). Hierarchical
|
(parameterized module) or Shape Y (thin-composition layer). Hierarchical
|
||||||
composition, max depth 5, only registered L1s. The thin-composition tree's
|
composition, max depth 5, only registered L1s. The thin-composition tree's
|
||||||
`wires` field is defined against the IR's relationship type, not a Terraform
|
`wires` field is defined against the IR's relationship type, not a
|
||||||
module block.
|
Terraform module block.
|
||||||
|
|
||||||
Pipeline quality checks: secrets-in-plaintext, public ingress, IAM
|
Pipeline quality checks: secrets-in-plaintext, public ingress, IAM
|
||||||
wildcard, KMS key reference, tag compliance, naming convention. Restricted
|
wildcard, KMS key reference, tag compliance, naming convention. Restricted
|
||||||
@@ -149,6 +162,10 @@ before contract submission ack); RTO = async worker's dead-letter recovery.
|
|||||||
Single-region in v1. The outbox also stores per-contract QA and prod
|
Single-region in v1. The outbox also stores per-contract QA and prod
|
||||||
approver identities (the only durable record outside GitHub's audit log).
|
approver identities (the only durable record outside GitHub's audit log).
|
||||||
|
|
||||||
|
> **v1.17 update:** the Decision Ledger (SQLite hash-chain, D-121) is the
|
||||||
|
> pilot's audit record. S3 Object Lock / JWS (D-083) is deferred — see
|
||||||
|
> the v1.17 addendum below.
|
||||||
|
|
||||||
### Human-in-the-Loop mechanics (§10)
|
### Human-in-the-Loop mechanics (§10)
|
||||||
Pre-execution gates. qa, prod, dr are PR-based attestation gates backed by
|
Pre-execution gates. qa, prod, dr are PR-based attestation gates backed by
|
||||||
GitHub Environments with required reviewers. No partial deployment to roll
|
GitHub Environments with required reviewers. No partial deployment to roll
|
||||||
@@ -181,28 +198,28 @@ platform does not run the skill. Stateless agents, all state in the
|
|||||||
platform. Skills are reviewed for sensitive data before release (Infra &
|
platform. Skills are reviewed for sensitive data before release (Infra &
|
||||||
Ops owns the review; it is the mandatory release gate).
|
Ops owns the review; it is the mandatory release gate).
|
||||||
|
|
||||||
### Angine execution (§12) — the binding constraint
|
### Engine execution (§12) — the binding constraint
|
||||||
**Target Stack IR** (locked): a engine-neutral description of resources
|
**Target Stack IR** (locked): an engine-neutral description of resources
|
||||||
(typed inputs/outputs/NFRs), relationships (single parent per child),
|
(typed inputs/outputs/NFRs), relationships (single parent per child),
|
||||||
composition (tree, max depth 5), and policy hooks. The L1 registry, L2
|
composition (tree, max depth 5), and policy hooks. The L1 registry, L2
|
||||||
thin-composition tree, contract YML, and PolicyCheckResult schema are all
|
thin-composition tree, contract YML, and PolicyCheckResult schema are all
|
||||||
defined against the IR — none against any specific engine.
|
defined against the IR — none against any specific engine.
|
||||||
|
|
||||||
**Angine adapters** are the only engine-specific code. An adapter
|
**Engine adapters** are the only engine-specific code. An adapter
|
||||||
compiles the IR into a engine execution plan. **v1 ships exactly one
|
compiles the IR into an engine execution plan. **v1 ships exactly one
|
||||||
adapter: the Terraform adapter.** v2+ may add OpenTofu, Pulumi, K8s CRDs
|
adapter: the Terraform adapter.** v2+ may add OpenTofu, Pulumi, K8s CRDs
|
||||||
without architectural change.
|
without architectural change.
|
||||||
|
|
||||||
v1 reality: the IR is shaped to round-trip cleanly to Terraform (nearly
|
v1 reality: the IR is shaped to round-trip cleanly to Terraform (nearly
|
||||||
isomorphic). As more adapters appear, the IR gets more expressive and the
|
isomorphic). As more adapters appear, the IR gets more expressive and the
|
||||||
adapters gain translation logic; the L1 content, the YML standard, and the
|
adapters gain translation logic; the L1 content, the YML standard, and
|
||||||
thin-composition tree do not change.
|
the thin-composition tree do not change.
|
||||||
|
|
||||||
**Terraform adapter (v1):** translates IR-typed L1 interface → Terraform
|
> **v1.11 update:** the Terraform adapter is now a **stateless assembler**
|
||||||
`variable`/`output` blocks; IR-typed L2 thin-composition tree → Terraform
|
> (~80 lines, emits `module "x" { source }` blocks) — see the v1.11
|
||||||
root module; IR-typed relationships → module references; emits a
|
> addendum below. The §12 "thin layer that translates IR → Terraform
|
||||||
`terraform plan` from the IR. The adapter is a thin layer; it does not own
|
> variable/output blocks" framing is superseded by the stateless-assembler
|
||||||
L1/L2 content.
|
> model; the L1-owns-its-shape invariant is the new contract.
|
||||||
|
|
||||||
State storage: S3 (state) + DynamoDB (locking), cloud-managed,
|
State storage: S3 (state) + DynamoDB (locking), cloud-managed,
|
||||||
single-region in v1.
|
single-region in v1.
|
||||||
@@ -211,6 +228,10 @@ Policy toolchain: **Checkov** for Terraform plan policy (the L2 checks +
|
|||||||
tag/naming); **Kyverno** for K8s-native/platform-internal policy; **OPA**
|
tag/naming); **Kyverno** for K8s-native/platform-internal policy; **OPA**
|
||||||
reserved for cross-resource cases, explicitly last resort.
|
reserved for cross-resource cases, explicitly last resort.
|
||||||
|
|
||||||
|
> **v1.25 update:** the policy toolchain is now unified under the
|
||||||
|
> swappable `PolicyEngine` protocol — see §12.7 below. Checkov and Wiz
|
||||||
|
> remain as raw-finding adapters feeding into kyverno-json meta-policies.
|
||||||
|
|
||||||
**Policy result normalization (§12.6):** the confidence signal consumes a
|
**Policy result normalization (§12.6):** the confidence signal consumes a
|
||||||
normalized `PolicyCheckResult` schema, not raw engine output.
|
normalized `PolicyCheckResult` schema, not raw engine output.
|
||||||
|
|
||||||
@@ -242,337 +263,9 @@ Contract→IR resolution: the contract declares intent in IR-typed terms;
|
|||||||
the pipeline resolves it to a target stack (list of L1 instances + inputs +
|
the pipeline resolves it to a target stack (list of L1 instances + inputs +
|
||||||
relationships); the Terraform adapter compiles the target stack to a plan.
|
relationships); the Terraform adapter compiles the target stack to a plan.
|
||||||
|
|
||||||
## v1.1 spike scope
|
---
|
||||||
|
|
||||||
The spike (Phases 08–10) materializes the **minimum** that proves the IR
|
## v1.11 Addendum — Stateless Adapter + Pipeline-Driven Lifecycle Testing (current state)
|
||||||
commitments hold (no polyglot mess):
|
|
||||||
|
|
||||||
- One L1: `l1-s3` (IR-typed interface; the only AWS resource in the spike).
|
|
||||||
- One L2 thin-composition: `l2-static-assets` (references `l1-s3` only).
|
|
||||||
- Terraform adapter: IR → `terraform plan` against AWS via OIDC.
|
|
||||||
- One contract submission → contract→IR → `terraform plan` → Checkov
|
|
||||||
`PolicyCheckResult` → confidence signal → evidence event to the DynamoDB
|
|
||||||
outbox.
|
|
||||||
- State: S3 + DynamoDB (real AWS, single-region).
|
|
||||||
|
|
||||||
Out of spike scope: full HITL matrix wiring, Kyverno, OPA, MCP skill
|
|
||||||
catalog, GitOps reconciler, multi-region, prod/dr environments, the 5-skill
|
|
||||||
L3B catalog. Those are post-spike (v1.2+) platform build-out.
|
|
||||||
|
|
||||||
## Gitea API surface (carried from v1.0, refined)
|
|
||||||
|
|
||||||
| Capability | Gitea support | ACDL approach (v1.1) |
|
|
||||||
|------------|---------------|----------------------|
|
|
||||||
| Org-scoped repo create | `POST /api/v1/orgs/{org}/repos` | Used for any new repos |
|
|
||||||
| Native Pages | **None** | Serve `acdl-evidence` via raw file URLs (unchanged from v1.0) |
|
|
||||||
| Environments API | **None**; act_runner ignores `environment:` | Model HITL gates via `workflow_dispatch` approval inputs (v1.0 D-013 pattern) — **refined in Phase 07** for the real pre-execution gate model |
|
|
||||||
| `repository_dispatch` | Not supported | Cross-repo trigger via `workflow_dispatch` API (unchanged) |
|
|
||||||
| Reusable workflows | Supported | `acdl/.gitea/workflows/pipeline.yml` via `uses: ...@<ref>` |
|
|
||||||
| `id-token: write` / OIDC | **Not supported** (RESEARCH TARGET 1, conf 0.95). Gitea docs list `id-token` as an unsupported GitHub-only scope; open proposal go-gitea/gitea#33681; draft PR go-gitea/gitea#36988 unmerged. Even Gitea's own CI uses long-lived AWS keys (issue #37980). | **Spike waiver D-039:** per-run-rotated long-lived key (rotated after each run by `scripts/rotate_spike_key.sh`). Real OIDC deferred to v1.2, blocked on PR #36988. |
|
|
||||||
| `actions/configure-aws-credentials` | Unusable without OIDC | Spike uses static AWS creds from a (rotated) Gitea Actions secret via the `aws-actions/configure-aws-credentials@v4` `access-key-id`/`secret-access-key` inputs, or plain `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars. v1.2 switches to `role-to-assume` when OIDC lands. |
|
|
||||||
|
|
||||||
### Branch pinning rule (refined for W2.A)
|
|
||||||
|
|
||||||
- Dev/qa contracts reference the reusable workflow by **tag**
|
|
||||||
(`@v1.1-spike`).
|
|
||||||
- Prod-bound workflows reference by **SHA**; the platform CLI
|
|
||||||
(`platform/cli/resolve-tag.ts`, Phase 07) resolves the current tag to its
|
|
||||||
SHA. (Spike scope: the CLI is a stub; the real CLI lands in v1.2.)
|
|
||||||
|
|
||||||
### Verification toolchain
|
|
||||||
|
|
||||||
ACDL has no `package.json`. The verification gate substitutes:
|
|
||||||
- **typecheck:** `terraform validate`, `python3 -m py_compile`, JSON Schema
|
|
||||||
validation (`ajv` or `python -m jsonschema`) against `schemas/`.
|
|
||||||
- **test:** per-phase `scripts/verify_phaseNN.sh` (Phase 06: archive integrity;
|
|
||||||
Phase 07: schema validation + decision-resolution completeness; Phase 08:
|
|
||||||
OIDC assume-role + state backend; Phase 09: IR + L1 + adapter `terraform
|
|
||||||
plan`; Phase 10: end-to-end contract submission).
|
|
||||||
- **build:** `terraform init` (real build for the spike).
|
|
||||||
- See `PERSONAS.md` verification_toolchain.
|
|
||||||
|
|
||||||
## Build order (v1.1)
|
|
||||||
|
|
||||||
1. Phase 06 — archive demo, reorient repo.
|
|
||||||
2. Phase 07 — finalize architecture v1.0; author schemas + designs.
|
|
||||||
3. Phase 08 — AWS OIDC bootstrap (use temp key once, rotate).
|
|
||||||
4. Phase 09 — IR + `l1-s3` + Terraform adapter → `terraform plan`.
|
|
||||||
5. Phase 10 — `l2-static-assets` + contract→IR → end-to-end spike.
|
|
||||||
6. COMPLETE gate — review → ship `v1.2.0` → audit. **DONE.**
|
|
||||||
|
|
||||||
## v1.2 build-out scope
|
|
||||||
|
|
||||||
v1.2 takes the v1.1 spike (dev-only, `plan`-only, single S3 L1) to a real,
|
|
||||||
simpler, better-documented platform that delivers a microservice to AWS ECS
|
|
||||||
Fargate end-to-end. The locked architecture (§1–§12) is unchanged — v1.2
|
|
||||||
extends the *implementation*, not the design.
|
|
||||||
|
|
||||||
### In scope (five axes, user-directed 2026-07-21)
|
|
||||||
|
|
||||||
1. **Re-evaluate the current state.** go-gitea/gitea#36988 (OIDC for Gitea
|
|
||||||
Actions) re-checked 2026-07-21: still **open** (last updated 2026-05-27,
|
|
||||||
not merged). Real OIDC remains deferred to v1.3+; v1.2 extends the D-039
|
|
||||||
per-run-rotated-key waiver as **D-047**. The waiver continues to satisfy
|
|
||||||
§12.5's *intent* (no *persistently* long-lived key): the spike key is
|
|
||||||
rotated after each run by `scripts/rotate_spike_key.sh`, and Phase 12
|
|
||||||
tightens the IAM scoping + rotation hygiene.
|
|
||||||
2. **NFR improvements on the existing spike.** Least-privilege IAM audit of
|
|
||||||
`spike_runner_policy.json`; idempotent `create_state_backend.py` /
|
|
||||||
`create_iam_user.py`; proper exit codes / error handling; P1-1 redaction
|
|
||||||
(two AWS access key IDs in `.ciagent/VERIFY.md` Phase 09 narrative).
|
|
||||||
3. **Streamline / simplify the current setup.** Consolidate
|
|
||||||
`run_spike_plan.sh` + `run_spike_e2e.sh` into one
|
|
||||||
`scripts/run_platform.sh`; remove dead code and stale `platform/` paths.
|
|
||||||
4. **README.md fully up to date on how the platform works.** Reflect v1.1
|
|
||||||
complete; document the actual spike flow, `scripts/run_platform.sh`, the
|
|
||||||
real repo layout, and the v1.2 objective.
|
|
||||||
5. **Bootstrap a consumer repo with a basic microservice deployed to ECS
|
|
||||||
end-to-end.** New Gitea repo `acdl-consumer-microservice` (org
|
|
||||||
`continuous-intelligence`); new IR-typed L1s (`l1-vpc`, `l1-ecs-cluster`,
|
|
||||||
`l1-ecs-service`, `l1-iam-role`, `l1-alb`, `l1-ecr`); new
|
|
||||||
`l2-microservice` thin-composition; one contract submission →
|
|
||||||
`terraform apply` (dev, autonomous per §10, confidence ≥ 0.50) → a live
|
|
||||||
ECS Fargate service serving HTTP 200 → evidence event to the DynamoDB
|
|
||||||
outbox → acdl-evidence timeline.
|
|
||||||
|
|
||||||
### Angine extension (ECS Fargate)
|
|
||||||
|
|
||||||
The Terraform adapter (§12) remains the only engine-specific code. v1.2
|
|
||||||
expands the adapter `TYPE_MAP` to cover the six new ECS-shaped IR resource
|
|
||||||
types. The L1 interface shape (IR-typed inputs/outputs/NFRs, registered in
|
|
||||||
`modules-ir/registry.json`) is unchanged — only the set of registered L1s
|
|
||||||
grows. The IR commitments (REQ-28) continue to hold: `modules-ir/`,
|
|
||||||
`schemas/`, `contracts/`, `core/confidence_signal.py`,
|
|
||||||
`core/contract_resolver.py`, `core/outbox_writer.py`
|
|
||||||
remain engine-agnostic.
|
|
||||||
|
|
||||||
### `terraform apply` (dev only)
|
|
||||||
|
|
||||||
v1.2 lifts the engine execution from `plan` to `apply` for the `dev`
|
|
||||||
environment only. Dev is autonomous per §10 (confidence ≥ 0.50, no HITL).
|
|
||||||
`apply` for qa/prod/dr remains HITL-gated and out of scope for v1.2. The
|
|
||||||
apply result (resources created, plan diff) is captured in the evidence
|
|
||||||
stream as a `terraform.apply` event.
|
|
||||||
|
|
||||||
### Out of scope for v1.2 (deferred to v1.3+)
|
|
||||||
|
|
||||||
| Feature | Reason |
|
|
||||||
|---------|--------|
|
|
||||||
| Real OIDC federation | go-gitea/gitea#36988 still open. v1.2 extends D-039 waiver (D-047); real OIDC is v1.3+. |
|
|
||||||
| Full HITL matrix wiring (qa/prod/dr) | v1.2 is dev-only autonomous `apply`; HITL wiring is v1.3. |
|
|
||||||
| Kyverno + OPA policy engines | v1.2 keeps Checkov only; Kyverno/OPA are v1.3. |
|
|
||||||
| MCP skill catalog + real L3B agent | v1.2 keeps the L3B stub; the 5-skill catalog is v1.3. |
|
|
||||||
| Audit ledger build-out (S3 Object Lock + JWS + async worker + DLQ + daily checkpoints) | v1.2 keeps the v1.1 outbox; the regulatory ledger is v1.3. |
|
|
||||||
| Multi-region state / outbox | Single-region in v1 (§9, §12.3); multi-region is v1.3+. |
|
|
||||||
| Prod/dr environments | v1.2 is dev-only; prod/dr are v1.3. |
|
|
||||||
| GitOps reconciler (ArgoCD/Flux) | v1.3+. |
|
|
||||||
|
|
||||||
## Build order (v1.2)
|
|
||||||
|
|
||||||
1. Phase 11 — re-eval #36988 + NFR audit + simplification findings + README rewrite.
|
|
||||||
2. Phase 12 — NFR harden + simplify (idempotent bootstrap, one `run_platform.sh`, IAM audit, redactions).
|
|
||||||
3. Phase 13 — six ECS L1s + adapter `TYPE_MAP` expansion.
|
|
||||||
4. Phase 14 — `l2-microservice` + contract schema extension.
|
|
||||||
5. Phase 15 — consumer repo + `terraform apply` (dev) → live ECS service.
|
|
||||||
6. Phase 16 — capstone e2e: consumer commit → live HTTP 200 → evidence → timeline.
|
|
||||||
7. COMPLETE gate — review → ship `v1.3.0` → audit.
|
|
||||||
|
|
||||||
## v1.8 Architecture Addendum
|
|
||||||
|
|
||||||
> Milestone v1.8 (complete, tag `v1.8.0`). Adds encryption-by-default,
|
|
||||||
> deletion-protection-by-default, uptime monitoring, decommission alias,
|
|
||||||
> engineering standards, and path documentation.
|
|
||||||
|
|
||||||
### New Primitives
|
|
||||||
|
|
||||||
- **`kms-key`** (`aws:kms:key`) — Per-stack customer-managed KMS key with
|
|
||||||
`enable_key_rotation = true`. One key per L2 deployment (no shared keys).
|
|
||||||
Wired into both L2 compositions as a child, with its `kms_key_arn` output
|
|
||||||
connected to all children's `kms_key_arn` input. Adapter emits
|
|
||||||
`aws_kms_key` + `enable_key_rotation`.
|
|
||||||
- **`uptime`** (`aws:ecs:uptime-service`) — Uptime-kuma on ECS Fargate with
|
|
||||||
a feature flag (`feature_flag_enabled`), monitored endpoints (HTTP/DNS/TCP),
|
|
||||||
alert channels (Teams/email/SMS/GitHub issues). Deployed by default after
|
|
||||||
any L2 module with a separate terraform state. When the feature flag is
|
|
||||||
false, the adapter emits no resources.
|
|
||||||
|
|
||||||
### Encryption by Default
|
|
||||||
|
|
||||||
All 12 L1 primitives have `encryption_enabled` NFR (default true). Primitives
|
|
||||||
with at-rest data (s3, rds, ecr, ecs-service, ecs-cluster) have an optional
|
|
||||||
`kms_key_arn` input. The adapter emits encryption blocks (SSE-KMS for S3,
|
|
||||||
storage_encrypted for RDS, encryption_configuration for ECR) referencing the
|
|
||||||
per-stack CMK when provided. Managed KMS fallback with stderr warning for
|
|
||||||
standalone L1 deployments.
|
|
||||||
|
|
||||||
### Deletion Protection by Default
|
|
||||||
|
|
||||||
All 12 L1 primitives have `deletion_protection` NFR (default true). The
|
|
||||||
adapter emits `lifecycle { prevent_destroy = true }` when true. L2 modules
|
|
||||||
expose a `features.deletion_protection` flag (default true) propagated to
|
|
||||||
all children via the resolver. Setting `inputs.deletion_protection: false`
|
|
||||||
in the contract disables it for the whole stack.
|
|
||||||
|
|
||||||
### Decommission Alias
|
|
||||||
|
|
||||||
A `mode: decommission` on the deploy pipeline implements a 2-step destroy:
|
|
||||||
1. Disable deletion protection (resolve with `deletion_protection: false`,
|
|
||||||
terraform plan/apply, HITL SRE gate via GitHub environment).
|
|
||||||
2. Zero counts + destroy (`decommission_transform` zeroes all scalable counts,
|
|
||||||
terraform plan/apply, second HITL SRE gate).
|
|
||||||
|
|
||||||
CMDB validation via DynamoDB `acdl-change-requests` table. The Lambda
|
|
||||||
`validate_change_request` action queries the table and asserts
|
|
||||||
`status == "approved"` + `consumerRepo` match.
|
|
||||||
|
|
||||||
### Adapter Expansion
|
|
||||||
|
|
||||||
TYPE_MAP grew from 16 to 19 entries (+ `aws:kms:key`, `aws:kms:alias`,
|
|
||||||
`aws:ecs:uptime-service`). Specialized emission branches added for KMS key
|
|
||||||
rotation, S3 SSE-KMS configuration, uptime ECS Fargate task, and
|
|
||||||
`prevent_destroy` lifecycle on all resources.
|
|
||||||
|
|
||||||
### Pipeline Stages
|
|
||||||
|
|
||||||
The deploy pipeline grew from 8 to 9 stages (+ `deploy-uptime` after
|
|
||||||
`publish-outputs`). The `deploy-uptime` stage constructs a synthetic uptime
|
|
||||||
contract from the L2 stack outputs, resolves + adapts it to a separate
|
|
||||||
terraform state directory, and publishes the uptime URL via PR comment.
|
|
||||||
|
|
||||||
### Forge-Agnostic API URLs
|
|
||||||
|
|
||||||
The platform Lambda (`contract_ingestor.py`) reads `GITHUB_API_BASE` env
|
|
||||||
for forge-agnostic API URLs. GitHub uses `/search/issues`; Gitea uses
|
|
||||||
`/repos/{owner}/{repo}/issues`. Detection via `/api/v1` in the base URL.
|
|
||||||
|
|
||||||
## v1.9 Addendum (2026-07-23)
|
|
||||||
|
|
||||||
### New Components
|
|
||||||
|
|
||||||
- **`core/contract_resolver.py` interpolation** (D-081): the resolver
|
|
||||||
now expands `${env.<field>}` + `${contract.<field>}` tokens
|
|
||||||
post-schema-validation, pre-IR-resolution. The env context is the
|
|
||||||
loaded environment onboarding JSON (`core/environments/<name>.json`,
|
|
||||||
schema `schemas/environment.schema.json`). The resolver's
|
|
||||||
`child_input_map` routes L2 wires to the sub-resource that declares the
|
|
||||||
input (P1-1 — `desired_count` → `aws:ecs:service`, `family` →
|
|
||||||
`aws:ecs:task_definition`).
|
|
||||||
- **`core/environment_check.py` `load()`** (REQ-104): loads + returns the
|
|
||||||
parsed environment JSON; emits a stderr warning for placeholder
|
|
||||||
`account_id` when env != dev.
|
|
||||||
- **`core/hitl_gates.py`** (REQ-108, D-084): the HITL pre-execution
|
|
||||||
attestation gate. Records the approver identity to the DynamoDB outbox
|
|
||||||
(`approver_qa`/`approver_prod`/`approver_dr`), runs the separation-of-
|
|
||||||
duties check on prod, invokes the attestation matrix, returns
|
|
||||||
`(ok, reason)`. Dev skips (autonomous). `run_platform.sh` calls
|
|
||||||
`attest` before apply for qa/prod/dr.
|
|
||||||
- **`core/attestation_matrix.py`** (REQ-109, D-084): the 8-concern
|
|
||||||
attestation matrix from `hitl_matrix_design.md` §10.4. Offline-testable
|
|
||||||
concerns (contract NFRs, schema validity, policy pass) run for real;
|
|
||||||
operator-supplied concerns accept signed evidence artifacts validated
|
|
||||||
for freshness + schema. Signature verification skips when
|
|
||||||
`ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (D-089).
|
|
||||||
- **`core/separation_of_duties.py` `route_halt_artifact`** (REQ-107):
|
|
||||||
real SNS publish (`acdl-sod-halt` topic, ARN from
|
|
||||||
`ACDL_SOD_HALT_TOPIC_ARN`) + outbox fallback
|
|
||||||
(`SEPARATION_OF_DUTIES_VIOLATION` event). The SNS topic is defined in
|
|
||||||
`terraform/platform/main.tf`.
|
|
||||||
- **`adapters/wiz/wiz_adapter.py` `WizClient`** (REQ-110): real GraphQL
|
|
||||||
API client (`<WIZ_API_URL>/graphql`, Bearer auth, pagination via
|
|
||||||
`pageInfo.hasNextPage`). `fetch_and_adapt` translates issues →
|
|
||||||
`PolicyCheckResult`. Graceful degrade when unconfigured.
|
|
||||||
- **`adapters/kyverno/kyverno_adapter.py`** (REQ-111): fleshed-out
|
|
||||||
`PolicyReport` → `PolicyCheckResult` mapping (pass/fail/skip/warn +
|
|
||||||
severity + skip-with-reason + resource construction). Inactive-for-TF
|
|
||||||
guard preserved.
|
|
||||||
|
|
||||||
### Per-Environment Promotion (D-082)
|
|
||||||
|
|
||||||
The deploy workflow (`.github/workflows/deploy.yml` +
|
|
||||||
`.gitea/workflows/deploy.yml`, byte-identical) declares an `environment`
|
|
||||||
`workflow_call` input. When non-empty, `run_platform.sh --environment
|
|
||||||
<name>` overrides the contract's `environment` field before schema
|
|
||||||
validation (D-088). One CI job per environment; promotion = running the
|
|
||||||
matching job, no `environment:` field editing. Per-env contract files
|
|
||||||
(`contracts/<module>.<env>.yaml`) use interpolation for env-specific
|
|
||||||
values.
|
|
||||||
|
|
||||||
### Adapter Parameterization (P1-1, D-085)
|
|
||||||
|
|
||||||
The adapter (`adapters/terraform/adapter.py`) reads ECS/ALB/VPC defaults
|
|
||||||
from L1 `interface.json` inputs (`desired_count`, `launch_type`,
|
|
||||||
`family`, `target_type`, `load_balancer_type`, `name`). The adapter is a
|
|
||||||
thin translator; the `child_input_map` routes wires to the declaring
|
|
||||||
sub-resource.
|
|
||||||
|
|
||||||
### Deferred (D-083)
|
|
||||||
|
|
||||||
S3 Object Lock + JWS detached signatures + async worker + DLQ + daily
|
|
||||||
checkpoints (audit ledger build-out) — deferred to a future milestone.
|
|
||||||
The hash-chain + DynamoDB-outbox path remains the v1.9 production audit
|
|
||||||
record.
|
|
||||||
|
|
||||||
## v1.10 Addendum — Regression VERIFY + Local Emulators + Capability Re-Verification
|
|
||||||
|
|
||||||
### Regression-Class VERIFY (D-091, `core/regression_verify.py`)
|
|
||||||
|
|
||||||
The standard VERIFY stage was diff-scoped (it checked the phase diff
|
|
||||||
only, never re-ran underlying capability). This let 8 NFR-patch phases
|
|
||||||
(v1.9.1–v1.9.8) pass while the platform decayed. The regression-class
|
|
||||||
VERIFY (`core/regression_verify.py`) re-runs capability checks against
|
|
||||||
the current codebase and tags each Verified/Decayed/Broken. It fails
|
|
||||||
closed on any non-Verified capability, blocking milestone completion.
|
|
||||||
|
|
||||||
The registry (`CAPABILITY_REGISTRY`) holds 16 capability checks
|
|
||||||
(CAP-001..CAP-016): 12 local-tier + 4 live-AWS. Adding a capability is
|
|
||||||
a single function + one registry entry. The gate runs via
|
|
||||||
`scripts/run_regression.sh` and writes `.ciagent/REGRESSION_REPORT.md`
|
|
||||||
+ `.json`.
|
|
||||||
|
|
||||||
### Local Emulating Adapters (D-092, `core/local_emulators.py`)
|
|
||||||
|
|
||||||
Four local adapters let the platform run the full headline E2E without
|
|
||||||
cloud credentials:
|
|
||||||
|
|
||||||
- `FlatFileOutbox` — flat-file DynamoDB outbox emulator (hash-chained
|
|
||||||
JSONL; resumable across instances; chain verification).
|
|
||||||
- `LocalEcsEmulator` — local ECS Fargate HTTP 200 emulator (binds port
|
|
||||||
0 on 127.0.0.1; daemon thread; clean destroy).
|
|
||||||
- `LocalS3StateBackend` — rewrites the terraform S3 backend to a local
|
|
||||||
backend (per-stack tfstate in a temp folder).
|
|
||||||
- `LocalLambdaStub` — invokes the contract_ingestor handler in-process
|
|
||||||
(patches `_get_dynamodb`/`_get_secrets_client`/`urllib.urlopen`;
|
|
||||||
DynamoDB writes redirected to the FlatFileOutbox).
|
|
||||||
|
|
||||||
`run_local_e2e()` runs the full pipeline: contract → resolver → adapter
|
|
||||||
→ local S3 backend → local ECS (HTTP 200) → flat-file outbox (chain
|
|
||||||
verified) → local Lambda (200). Gated on `ACDL_LOCAL_TIER=1`.
|
|
||||||
|
|
||||||
### Capability Re-Verification Sweep (D-093)
|
|
||||||
|
|
||||||
`.ciagent/CAPABILITY_INVENTORY.md` enumerates 16 auto-verified
|
|
||||||
capabilities + 6 IAM-gated escalated resources. The sweep found and
|
|
||||||
fixed 7 adapter defects in `adapters/terraform/adapter.py` (duplicate
|
|
||||||
outputs, duplicate args, missing required args, deprecated AWS provider
|
|
||||||
v5 arg names). The headline E2E now passes at both tiers: local
|
|
||||||
emulator + live-AWS terraform init/validate/plan.
|
|
||||||
|
|
||||||
### Adapter Defect Fixes (P54)
|
|
||||||
|
|
||||||
7 defects fixed in `adapters/terraform/adapter.py`:
|
|
||||||
1. Duplicate output definitions (per-resource + stack-level both emitted).
|
|
||||||
2. Duplicate `desired_count`/`launch_type` on ECS service.
|
|
||||||
3. Duplicate `target_type`/`family`/`load_balancer_type`.
|
|
||||||
4. Missing `assume_role_policy`/`role_name` on IAM role (L2 composition gap).
|
|
||||||
5. Missing `cidr_block`/`vpc_id`/`name` defaults on VPC/subnet/route_table/
|
|
||||||
ECS cluster/ECR repository.
|
|
||||||
6. ECR `kms_key_arn` unsupported arg → `encryption_configuration` block.
|
|
||||||
7. CloudFront OAC + WAF deprecated arg names (AWS provider v5):
|
|
||||||
`signing_behavior`, `signing_protocol`, `origin_access_control_id`,
|
|
||||||
`s3_origin_config.origin_access_identity`, `origin_id`, `rule`
|
|
||||||
(singular), `scope=CLOUDFRONT` (uppercase).
|
|
||||||
|
|
||||||
## v1.11 Addendum — Stateless Adapter + Pipeline-Driven Lifecycle Testing
|
|
||||||
|
|
||||||
**Stateless adapter (D-098).** `adapters/terraform/adapter.py` rewritten
|
**Stateless adapter (D-098).** `adapters/terraform/adapter.py` rewritten
|
||||||
from a 918-line monolith (3 constant tables `TYPE_MAP`/`INPUT_MAP`/
|
from a 918-line monolith (3 constant tables `TYPE_MAP`/`INPUT_MAP`/
|
||||||
@@ -598,83 +291,25 @@ VPC; the microservice composition references it via
|
|||||||
`terraform_remote_state` (data source). State keys are deterministic and
|
`terraform_remote_state` (data source). State keys are deterministic and
|
||||||
env-aware (`spike/{contract.id}/{contract.environment}/terraform.tfstate`).
|
env-aware (`spike/{contract.id}/{contract.environment}/terraform.tfstate`).
|
||||||
|
|
||||||
**NOVA_LIFECYCLE_MODE (v1.12, REQ-134; renamed ACDL→NOVA in v1.15 P2).** The lifecycle pipeline defaults
|
**NOVA_LIFECYCLE_MODE (v1.12, REQ-134; renamed ACDL→NOVA in v1.15 P2).**
|
||||||
to plan-only (fast, no AWS mutation, no cost). A CI variable
|
The lifecycle pipeline defaults to plan-only (fast, no AWS mutation, no
|
||||||
`NOVA_LIFECYCLE_MODE` (default `plan`) overrides to `full` for the real
|
cost). A CI variable `NOVA_LIFECYCLE_MODE` (default `plan`) overrides to
|
||||||
apply→modify→destroy. (P2–P4 dual-read fallback to `ACDL_LIFECYCLE_MODE`;
|
`full` for the real apply→modify→destroy. (P2–P4 dual-read fallback to
|
||||||
fallback removed in P5 per the v1.15 addendum.)
|
`ACDL_LIFECYCLE_MODE`; fallback removed in P5 per the v1.15 addendum.)
|
||||||
|
|
||||||
## v1.12 Addendum — Presentation Refinement + CAP-013 Fix
|
|
||||||
|
|
||||||
**CAP-013 adapter dedup fix (REQ-129).** Multi-resource L1s (ecs-service,
|
|
||||||
alb) with stack outputs + cross-module refs now dedup to ONE module block
|
|
||||||
named by the composition child id, with expanded sub-ids rewritten via
|
|
||||||
`id_remap`. `terraform validate` succeeds for the microservice stack.
|
|
||||||
|
|
||||||
**CAP-017/018 probe fixes (REQ-130).** CAP-017's probe no longer requires
|
|
||||||
`locals.tf` for modules that legitimately omit it. CAP-018's probe
|
|
||||||
instantiates `LocalLambdaStub` with the required `outbox` arg.
|
|
||||||
|
|
||||||
## v1.13 Addendum — Presentation Polish + Config Schema Migration
|
|
||||||
|
|
||||||
**Config.json schema migration (v1.13.1).** Regenerated
|
|
||||||
`.ciagent/config.json` to the updated CIAgent v2 config structure (drop
|
|
||||||
removed fields, migrate `gitea`→`release.gitea`, add
|
|
||||||
`secrets`/`ship`/`backend`/`ideation`/`personas`/`logging`/`telemetry`
|
|
||||||
sections).
|
|
||||||
|
|
||||||
**Presentation polish (v1.13.0, v1.13.2).** Action headlines, story-arc
|
|
||||||
restructure, larger fonts, 6 new mermaid diagrams, badge cleanup,
|
|
||||||
platform-architecture diagram. Docs-only NFR patches.
|
|
||||||
|
|
||||||
## v1.14 Addendum — NFR Refinement (bug fixes, security, stubs, tests, docs)
|
|
||||||
|
|
||||||
**Bug fixes (Wave 1, P1-P6).** Adapter dedup rejects unregistered modules
|
|
||||||
with ValueError (P1). Static-assets composition wires cloudfront inputs
|
|
||||||
(P2). L2 lifecycle scripts document remote-state design (P3). Regression
|
|
||||||
gate adds `terraform fmt -check` syntax probe (P4). Adapter dedup-merge +
|
|
||||||
remote-state-key unit tests (P5). ALB target group name_prefix derives
|
|
||||||
from var.name (P6).
|
|
||||||
|
|
||||||
**Security (Wave 2, P7-P12).** 6 swallowed-error sites narrowed to
|
|
||||||
specific exceptions (P7). Account ID externalized to
|
|
||||||
`ACDL_AWS_ACCOUNT_ID` env (P8). IAM policy scoped to `acdl-*` ARNs (P9).
|
|
||||||
Contract ingestor validates contractId/environment/error (P10). Environment
|
|
||||||
schema adds `additionalProperties: false` + format validation (P11).
|
|
||||||
`.gitignore` credential-pattern catch-all (P12).
|
|
||||||
|
|
||||||
**Stub/test/CI/hygiene (Wave 3, P13-P17).** Kyverno `--kube-version` flag
|
|
||||||
removed (P13, G-103). Orphan artifacts + dead config cleaned (P14). 7
|
|
||||||
untested scripts gain test coverage (P15). Gitea workflow parity
|
|
||||||
documented + script `set` flags fixed (P16). Config.json persona +
|
|
||||||
branching strategy + ollama-cloud aligned (P17).
|
|
||||||
|
|
||||||
**Standards/docs/VPC (Wave 4, P18-P20).** STANDARDS.md reconciled (P18).
|
|
||||||
Documentation synced: ARCHITECTURE.md addenda, stale `@v1.6-1.9` → `@v1.13`,
|
|
||||||
GRILL G-005/G-008 resolved, COST.md window extended, D-083 deferral
|
|
||||||
recorded (P19). Platform VPC CIDR parameterized + data-driven subnet
|
|
||||||
count (P20).
|
|
||||||
|
|
||||||
**D-083 deferral (explicit).** The audit ledger build-out (S3 Object Lock
|
|
||||||
+ JWS detached signatures + SQS DLQ + async worker + daily checkpoints)
|
|
||||||
remains deferred (D-096, v1.14). The hash-chain + DynamoDB outbox is the
|
|
||||||
v1.14 audit record. JWS per-event authenticity is not implemented; a
|
|
||||||
forged event is only detectable by re-reading the whole chain. The
|
|
||||||
deferral is documented here explicitly per the v1.14 grill (E-001).
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.15 Addendum — Nova Rebrand (Major/breaking, 2026-07-30)
|
## v1.15 Addendum — Nova Rebrand (current naming)
|
||||||
|
|
||||||
**Milestone:** v1.15-Nova. A full rebrand from **ACDL** / "Agentic Cloud
|
**Milestone:** v1.15-Nova. A full rebrand from **ACDL** / "Agentic Cloud
|
||||||
Delivery Platform" → **Nova** / "The New Dawn of DevSecOps — security
|
Delivery Platform" → **Nova** / "The New Dawn of DevSecOps — security as
|
||||||
as a seamless enabler of fast deployments." This is a **Major
|
a seamless enabler of fast deployments." This is a **Major milestone**
|
||||||
milestone** (breaking): consumer-facing path, env var prefixes, SSM
|
(breaking): consumer-facing path, env var prefixes, SSM path, AWS tag
|
||||||
path, AWS tag keys, and AWS resource names all change. Per the
|
keys, and AWS resource names all change. v1.15 tags run on the **v1.15.x
|
||||||
branch-strategy precedent (breaking/feature milestones tag on their
|
minor line**: `v1.15.0` (P0) → `v1.15.4` (P5 final = release). (G-104
|
||||||
OWN minor line), v1.15 tags run on the **v1.15.x minor line**:
|
binding.)
|
||||||
`v1.15.0` (P0) → `v1.15.4` (P5 final = release). (G-104 binding.)
|
|
||||||
|
|
||||||
### Naming conventions (rebranded)
|
### Naming conventions (rebranded — current)
|
||||||
|
|
||||||
| Convention | Before (v1.0–v1.14) | After (v1.15+) | Phase |
|
| Convention | Before (v1.0–v1.14) | After (v1.15+) | Phase |
|
||||||
|------------|---------------------|-----------------|-------|
|
|------------|---------------------|-----------------|-------|
|
||||||
@@ -713,94 +348,15 @@ OWN minor line), v1.15 tags run on the **v1.15.x minor line**:
|
|||||||
brand name present (D-112: flat-branch convention preserved).
|
brand name present (D-112: flat-branch convention preserved).
|
||||||
- **Past Gitea release titles** — existing releases keep `ACDL vX.Y.Z`.
|
- **Past Gitea release titles** — existing releases keep `ACDL vX.Y.Z`.
|
||||||
|
|
||||||
### Migration ordering (binding)
|
> The full migration ordering (P1–P5), capability gate, and rollback
|
||||||
|
> runbook are preserved in `.ciagent/archive/ARCHITECTURE-v1.0-v1.24.md`
|
||||||
1. **P1** docs/decks/prose — no runtime impact; ships consumer migration
|
> §v1.15 Addendum.
|
||||||
guide announcing the 5 breaking changes.
|
|
||||||
2. **P2** code + env vars (dual-read) + consumer path — deployments don't
|
|
||||||
break during the transition window (dual-read fallback).
|
|
||||||
3. **P3** SSM path (copy → read → delete) + tag keys (parallel-tag →
|
|
||||||
policy swap → remove old).
|
|
||||||
4. **P4** AWS resource names — staged terraform migration (KMS alias,
|
|
||||||
SNS/SG/Lambda recreate, DynamoDB scan+copy, ECR re-push, IAM
|
|
||||||
re-bootstrap, state bucket `-migrate-state`, ALB recreate). Maintenance
|
|
||||||
window + rollback runbook (`docs/NOVA_AWS_MIGRATION.md`).
|
|
||||||
5. **P5** final review + audit + remove dual-read fallback + milestone ship.
|
|
||||||
|
|
||||||
### Capability gate (binding)
|
|
||||||
|
|
||||||
The regression gate (CAP-001..CAP-016, `scripts/run_regression.sh`) must
|
|
||||||
stay **16/16 Verified** throughout the rebrand. P2/P3/P4 update test
|
|
||||||
fixtures that reference `ACDL`/`acdl` so the gate stays green. No
|
|
||||||
capability is added, removed, or reclassified in v1.15 — the rebrand is
|
|
||||||
nomenclature + identifiers, not behavior.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.16 Addendum — Nova Simplification (NFR, 2026-07-30)
|
## v1.17 Addendum — Strategic Direction, Leadership Metrics & Unified Story (current telemetry layer)
|
||||||
|
|
||||||
The v1.16 NFR milestone added 6 new code components + 1 new Terraform
|
The v1.17 milestone added a telemetry/observability layer, a Decision
|
||||||
module + 1 new schema, all documented here for the architecture record.
|
|
||||||
|
|
||||||
### New components
|
|
||||||
|
|
||||||
| Component | Path | Purpose |
|
|
||||||
|-----------|------|---------|
|
|
||||||
| Onboarding request handler | `core/onboarding.py` | `generate_env_file(request, template_env)` — produces a `<env>.json` from a consumer onboarding request (P19, REQ-183). CLI entry point for self-service env-file generation. |
|
|
||||||
| Decommission transform | `core/decommission_transform.py` | `decommission_transform(stack)` — zero counts + disable deletion protection (REQ-92). Extracted from contract_resolver (P12, REQ-176). |
|
|
||||||
| Contract resolver CLI | `core/contract_resolver_cli.py` | `main()` CLI entry point — resolves a contract YAML to a Target Stack JSON. Extracted from contract_resolver (P12, REQ-176). |
|
|
||||||
| Regression verify CLI | `core/regression_verify_cli.py` | `main()` CLI entry point — runs the regression gate + writes the report. Extracted from regression_verify (P13, REQ-177). |
|
|
||||||
| Workflow sync generator | `scripts/sync_workflows.py` | `--check`/`--write` — generates the 3 byte-identical Gitea+GitHub workflow pairs from `workflows-src/` (P8, REQ-172). |
|
|
||||||
| Onboarding Terraform | `terraform/onboarding/` | `aws_iam_role.consumer_deploy` + `aws_iam_role_policy.consumer_invoke` (ABAC `nova:owner` tag). Offline-proven only (P20, REQ-184, D-114). |
|
|
||||||
|
|
||||||
### Modified components
|
|
||||||
|
|
||||||
| Component | Change | Phase |
|
|
||||||
|-----------|--------|-------|
|
|
||||||
| `core/contract_resolver.py` | `_load_env` delegates to `environment_check.load()` (dedup); `is_l2` uses registry `kind` field; `_load_schema` caches schemas; `decommission_transform` + CLI re-export shim (P12). | P7, P12, P14 |
|
|
||||||
| `core/regression_verify.py` | Dedup helpers (`_check_resolver`, `_check_live_terraform_plan`, `_assert_contracts_resolve`); CAP-013..016 `Skipped` on post-teardown (G-111); `passed` accepts Skipped; CLI re-export shim (P13). | P5, P9, P13 |
|
|
||||||
| `core/lambda/contract_ingestor.py` | Fail closed on missing IAM identity (P10); env enum from `core/environments/` (P10); payload size cap + schema validation (P11); `onboard_consumer` action (P18); `[NOVA-ALERT]` rebrand (P2). | P2, P10, P11, P18 |
|
|
||||||
| `core/output_publisher.py` | `SAFE_OUTPUT_NAMES` schema-driven from `interface.json`; narrowed excepts; `urllib.error` import (P4, P14). | P4, P14 |
|
|
||||||
| `core/environment_check.py` | Onboarding message rebranded Nova + self-service request path (P2, P19). | P2, P19 |
|
|
||||||
| `core/local_emulators.py` | `LocalLambdaStub` sets `NOVA_LAMBDA_LOCAL_BYPASS`; stale dual-read comments + `acdl_*` prefixes removed (P3, P10). | P3, P10 |
|
|
||||||
| `scripts/run_platform.sh` | `--help` flag; `run_hitl_gate()` fn; `NOVA_CONTRACT_ID`/`NOVA_WORK_DIR` config; decommission + uptime blocks extracted to sourced helpers (P6, P9, P15). | P6, P9, P15 |
|
|
||||||
| `adapters/terraform/adapter.py` | State bucket `nova-tfstate-*` (P1); module docstring Nova (P2). | P1, P2 |
|
|
||||||
| `adapters/kyverno/policies/require-resource-labels.yml` | `nova:*` labels (not `acdl:*`) (P1). | P1 |
|
|
||||||
| `modules/registry.json` | `kind` field (`l1`/`l2`) on all 14 entries (P7). | P7 |
|
|
||||||
|
|
||||||
### New schema
|
|
||||||
|
|
||||||
- `schemas/onboarding.schema.json` — the self-service onboarding request
|
|
||||||
(consumerRepo, requestedEnvironment, ownerId, billingTag). P18, REQ-182.
|
|
||||||
|
|
||||||
### Onboarding request-path architecture (D-113)
|
|
||||||
|
|
||||||
The no-humans onboarding flow is a 3-step request path (real AWS
|
|
||||||
provisioning deferred):
|
|
||||||
|
|
||||||
```
|
|
||||||
Consumer → POST Lambda (onboard_consumer) → pending CMDB row (P18)
|
|
||||||
→ core/onboarding.py → <env>.json binding file (P19)
|
|
||||||
→ terraform/onboarding/ → cross-account role + ABAC tag (P20, offline)
|
|
||||||
```
|
|
||||||
|
|
||||||
The Lambda Function URL (IAM auth) + `consumer_invoke_policy.json` (ABAC
|
|
||||||
`nova:owner`) are the transport; the request is accepted + a binding
|
|
||||||
generated + the role Terraform proven offline. No AWS resources are
|
|
||||||
created by the request path (D-113/D-114).
|
|
||||||
|
|
||||||
### Regression gate (G-111 binding)
|
|
||||||
|
|
||||||
The regression gate (D-091) now treats `Skipped` as acceptable for the
|
|
||||||
post-v1.11-teardown steady state (D-096): CAP-013..016 (live-AWS tier)
|
|
||||||
return `Skipped` when the resources are absent (`NoSuchBucket`/
|
|
||||||
`ResourceNotFoundException`). `RegressionReport.passed` is
|
|
||||||
`all(r.status in ("Verified", "Skipped"))`. The gate passes at 18
|
|
||||||
Verified + 4 Skipped (0 Decayed/Broken).
|
|
||||||
|
|
||||||
## v1.17 Addendum — Strategic Direction, Leadership Metrics & Unified Story (2026-08-04)
|
|
||||||
|
|
||||||
The v1.17 milestone adds a telemetry/observability layer, a Decision
|
|
||||||
Ledger, a metrics export pipeline, a unified narrative deck, and a
|
Ledger, a metrics export pipeline, a unified narrative deck, and a
|
||||||
durable strategic-direction artifact. This addendum documents the
|
durable strategic-direction artifact. This addendum documents the
|
||||||
architecture; the full research findings are in RESEARCH.md §v1.17.
|
architecture; the full research findings are in RESEARCH.md §v1.17.
|
||||||
@@ -840,26 +396,26 @@ architecture; the full research findings are in RESEARCH.md §v1.17.
|
|||||||
│ Nova platform components (existing) │
|
│ Nova platform components (existing) │
|
||||||
│ run_platform.sh · confidence_signal · checkov_adapter · │
|
│ run_platform.sh · confidence_signal · checkov_adapter · │
|
||||||
│ hitl_gates · regression_verify · outbox_writer · contract_ingestor │
|
│ hitl_gates · regression_verify · outbox_writer · contract_ingestor │
|
||||||
└──────────────────────┬──────────────────────────────────────────────┘
|
└────────────────────┬──────────────────────────────────────────────┘
|
||||||
│ CloudEvents 1.0 envelope (new emitters, P1)
|
│ CloudEvents 1.0 envelope (new emitters, P1)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ metrics/events.jsonl (append-only CloudEvents log) │
|
│ metrics/events.jsonl (append-only CloudEvents log) │
|
||||||
│ metrics/runs/<run_id>.json (per-run manifests) │
|
│ metrics/runs/<run_id>.json (per-run manifests) │
|
||||||
│ metrics/decision_ledger.db (SQLite hash-chain, D-121) │
|
│ metrics/decision_ledger.db (SQLite hash-chain, D-121) │
|
||||||
│ metrics/test-results.xml (junit, P1) │
|
│ metrics/test-results.xml (junit, P1) │
|
||||||
└──────────────────────┬──────────────────────────────────────────────┘
|
└────────────────────┬──────────────────────────────────────────────┘
|
||||||
│ collector reads (P2)
|
│ collector reads (P2)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ metrics/nova_metrics.db (SQLite cold store, D-126) │
|
│ metrics/nova_metrics.db (SQLite cold store, D-126) │
|
||||||
│ fact_run · fact_capability · fact_policy_check · fact_confidence │
|
│ fact_run · fact_capability · fact_policy_check · fact_confidence │
|
||||||
│ fact_test · fact_decision · fact_cost_estimate │
|
│ fact_test · fact_decision · fact_cost_estimate │
|
||||||
│ dim_capability · dim_milestone │
|
│ dim_capability · dim_milestone │
|
||||||
│ + 8 empty placeholder views (deferred metrics) │
|
│ + 8 empty placeholder views (deferred metrics) │
|
||||||
└──────────────────────┬──────────────────────────────────────────────┘
|
└────────────────────┬──────────────────────────────────────────────┘
|
||||||
│ powerbi_export (P3)
|
│ powerbi_export (P3)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ metrics/powerbi/ (CSV/JSON views, folder connector, D-129) │
|
│ metrics/powerbi/ (CSV/JSON views, folder connector, D-129) │
|
||||||
│ → PowerBI dashboards (external) │
|
│ → PowerBI dashboards (external) │
|
||||||
@@ -868,19 +424,20 @@ architecture; the full research findings are in RESEARCH.md §v1.17.
|
|||||||
|
|
||||||
**Hot path: deferred (D-126).** No live ops dashboard; SQLite is
|
**Hot path: deferred (D-126).** No live ops dashboard; SQLite is
|
||||||
cold-only (batch/historical). The hot path activates when live AWS is
|
cold-only (batch/historical). The hot path activates when live AWS is
|
||||||
re-provisioned (D-096 lift).
|
re-provisioned (D-096 lift — the v1.26 milestone lifts this for the pilot
|
||||||
|
estate).
|
||||||
|
|
||||||
### NORTH_STAR integration point (REQ-186)
|
### NORTH_STAR integration point (REQ-186)
|
||||||
|
|
||||||
`.ciagent/NORTH_STAR.md` is read by CIAgent in context-loading for all
|
`.ciagent/NORTH_STAR.md` is read by CIAgent in context-loading for all
|
||||||
future milestones. The integration mechanism (to be finalized in P4):
|
future milestones. The integration mechanism: a reference from
|
||||||
a reference from `PROJECT.md` + `ARCHITECTURE.md` (this section) + a
|
`PROJECT.md` + `ARCHITECTURE.md` (this section) + a config entry in
|
||||||
config entry in `config.json` (`strategic_direction_file:
|
`config.json` (`strategic_direction_file: ".ciagent/NORTH_STAR.md"`)
|
||||||
".ciagent/NORTH_STAR.md"`) that the run workflow reads at SPECIFY. This
|
that the run workflow reads at SPECIFY. This ensures the strategic
|
||||||
ensures the strategic direction survives across milestones without
|
direction survives across milestones without being overwritten by status
|
||||||
being overwritten by status updates.
|
updates.
|
||||||
|
|
||||||
### §12.7 — Policy Engine Registry (v1.25, REQ-291)
|
### §12.7 — Policy Engine Registry (v1.25, REQ-291 — current)
|
||||||
|
|
||||||
The policy-engine abstraction is first-class: a swappable `PolicyEngine`
|
The policy-engine abstraction is first-class: a swappable `PolicyEngine`
|
||||||
protocol so the engine may change without touching the confidence
|
protocol so the engine may change without touching the confidence
|
||||||
@@ -943,3 +500,225 @@ functions without the binary (the "platform functions without AI /
|
|||||||
deterministic scripts" tenet holds — kyverno-json is deterministic, not
|
deterministic scripts" tenet holds — kyverno-json is deterministic, not
|
||||||
AI; the `is_configured()` guard ensures the platform runs even when the
|
AI; the `is_configured()` guard ensures the platform runs even when the
|
||||||
binary is not installed).
|
binary is not installed).
|
||||||
|
|
||||||
|
### §12.8 — Pilot Estate (v1.26, live)
|
||||||
|
|
||||||
|
The first real consumer estate is **`nova-blockchain-exchange`** — a
|
||||||
|
blockchain stock exchange on a homegrown Proof-of-Authority chain,
|
||||||
|
equities only, dev only (D-020/D-200/D-201). The live apply landed on
|
||||||
|
2026-08-19 against AWS account `581513795199`. This is the estate that
|
||||||
|
activated the Post-Pilot metric denominators (see `docs/METRICS.md`).
|
||||||
|
|
||||||
|
**The live apply (run id `blkex-pilot-apply-v0.2`):**
|
||||||
|
- Target: account `581513795199`, environment `dev`, autonomous (no
|
||||||
|
HITL — dev is the only autonomous environment, confidence ≥ 0.50).
|
||||||
|
- The microservice L2 composition (ECS Fargate running nginx) + the
|
||||||
|
`dynamodb` L1 (the `nova-blkex-ledger-dev` table) + the `s3` L1 (the
|
||||||
|
`nova-blkex-blocks-dev-581513795199-us-east-1` bucket).
|
||||||
|
- The platform VPC prerequisite (`vpc-0d7c8867e6cc080f1` + 6 subnets +
|
||||||
|
the ECS SG) is read via `terraform_remote_state` — the L2 composition
|
||||||
|
does not own the network boundary (the "restricted from
|
||||||
|
thin-composition" rule from §Layer 2).
|
||||||
|
- Confidence signal: score **0.800**, band **pass**; `human_override`
|
||||||
|
false; `escalation_reason` absent (clean apply).
|
||||||
|
|
||||||
|
**The Gitea adapter (SPEC §10 Q1):** Gitea Actions does not support
|
||||||
|
cross-repo `uses:`, so the consumer's `deploy.yml` is an **inline
|
||||||
|
adapter** — `actions/checkout@v4` the consumer, `actions/checkout@v4`
|
||||||
|
`acdl/acdl` @ `ref: v1.25` into `platform/`, then
|
||||||
|
`bash platform/scripts/run_platform.sh ...`. The platform's own
|
||||||
|
`.github/workflows/deploy.yml` stays as the GitHub Actions reference
|
||||||
|
impl (the reusable `workflow_call` workflow). See `adapters/README.md`
|
||||||
|
§Consumers for the adapter note.
|
||||||
|
|
||||||
|
**The Decision Ledger evidence stream** (the apply produces these
|
||||||
|
events in order):
|
||||||
|
```
|
||||||
|
nova.confidence.computed (score 0.800, band pass)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nova.ai.decision.made (decision_id blkex-pilot-apply-v0.2,
|
||||||
|
chosen_action pass, human_override false)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nova.attestation.recorded (dev = no HITL gate; the record exists,
|
||||||
|
the gate is a no-op in the autonomous env)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nova.run.completed (apply succeeded)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nova.outcome.backfilled (outcome pending → succeeded, REQ-317;
|
||||||
|
backfilled_at 2026-08-19T03:05:04Z)
|
||||||
|
```
|
||||||
|
The SQLite hash-chain is valid (0 breaks). S3 Object Lock / JWS
|
||||||
|
(D-083) stays deferred — the SQLite Decision Ledger is the pilot's
|
||||||
|
audit record (D-204).
|
||||||
|
|
||||||
|
**Live outputs (account 581513795199):**
|
||||||
|
- ALB DNS: `app-254671247.us-east-1.elb.amazonaws.com`
|
||||||
|
- ECS service: `arn:aws:ecs:us-east-1:581513795199:service/nova-cluster/nova-microservice`
|
||||||
|
- DynamoDB table: `nova-blkex-ledger-dev` (PK `block_index`, PAY_PER_REQUEST)
|
||||||
|
- S3 bucket: `nova-blkex-blocks-dev-581513795199-us-east-1` (versioning + SSE)
|
||||||
|
|
||||||
|
The full evidence (every ARN, the confidence JSON, the Decision Ledger
|
||||||
|
rows, the module-completeness gaps the live apply uncovered) is in
|
||||||
|
`.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` (archived v1.27).
|
||||||
|
|
||||||
|
### §12.9 — Secret Rotation (v1.26 P3 W7, SPEC §5.9 — current)
|
||||||
|
|
||||||
|
The platform-managed scheduled workflow `workflows-src/rotate-aws-key.yml`
|
||||||
|
rotates the `NOVA_AWS_*` static key daily (cron `0 0 * * *`) and on
|
||||||
|
`workflow_dispatch`. v0.2 scope: the mechanism exists (SPEC §5.9 —
|
||||||
|
exists-not-ran); the v0.2 deploy uses the currently-active key. The
|
||||||
|
rotation is idempotent — `scripts/rotate_spike_key.sh` deactivates the old
|
||||||
|
key only after the new one propagates to the consumer's Actions secret
|
||||||
|
store, verified by a post-PUT GET; on upload/verify failure the old key is
|
||||||
|
left Active and the run exits non-zero. The synced workflow file is
|
||||||
|
forge-agnostic (REQ-230): forge base URL / owner / consumer repo come from
|
||||||
|
repository secrets (`NOVA_FORGE_*`, `NOVA_CONSUMER_REPO`), not literals.
|
||||||
|
|
||||||
|
### §12.10 — Nova-idp Identity Layer (v1.28, current)
|
||||||
|
|
||||||
|
Nova owns its identity layer end-to-end. Two (optionally three) Lambda
|
||||||
|
functions + four DynamoDB tables + one KMS asymmetric signing key + one
|
||||||
|
kyverno-json ABAC policy. **No Cognito, no IAM Identity Center (INV-15).**
|
||||||
|
The `nova-cli` Lambda layer carries the Nova wheel + `argon2-cffi` +
|
||||||
|
`cryptography` + `pyjwt` + the `kj` Go binary, making the same code
|
||||||
|
importable in both the CLI and the Lambda (REQ-329 dual-use, NFR-7).
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
|
||||||
|
- `nova-idp-auth` Lambda — sign-up, sign-in, session creation. Argon2id
|
||||||
|
password hashing (D-228: bundled abi3 wheel; fail-closed on
|
||||||
|
`ImportError`, no pure-Python fallback). DynamoDB: `nova-users`
|
||||||
|
(PK `user_id`, Argon2id `password_hash`), `nova-sessions` (PK
|
||||||
|
`session_id`, TTL `expires_at`), `nova-password-resets` (PK
|
||||||
|
`reset_token`, TTL 15m). Function URL with IAM auth.
|
||||||
|
- `nova-idp-token-vend` Lambda — accepts a PAT (or session token),
|
||||||
|
validates revocation (`nova-pats.GetItem(jti, ConsistentRead=True)` —
|
||||||
|
D-229, 60s SLO), evaluates the kyverno-json ABAC policy at
|
||||||
|
`platform/abac/token-vend.policy` (D-227, INV-17), KMS-signs an
|
||||||
|
ECDSA P-256 JWT (`ES256`), converts DER→raw ECDSA signature (RFC 7515
|
||||||
|
§3.1.3), returns the OIDC token. The `policy_version` (git SHA,
|
||||||
|
D-231) is recorded in every `token.vend.allowed/denied` audit event.
|
||||||
|
- `nova-idp-jwks` Lambda (optional, separation of concerns) — function
|
||||||
|
URL with `AuthType: NONE` (public key only), `Cache-Control: max-age=3600`.
|
||||||
|
`kms.get_public_key` → DER SPKI → JWK via `cryptography`. Custom
|
||||||
|
domain + WAF via CloudFront is OPTIONAL (`--public-jwks-domain` flag
|
||||||
|
on `nova idp setup`, D-230).
|
||||||
|
- `nova-pats` DynamoDB table — PK `jti`, GSI1 `sub` (list PATs for
|
||||||
|
user), GSI2 `pat_hash` (lookup by hash). Only the hash stored (not
|
||||||
|
raw PAT, REQ-343). Revoked PATs retained for audit.
|
||||||
|
|
||||||
|
**CLI surface (`nova` package, greenfield):**
|
||||||
|
|
||||||
|
- Entry point: `[project.scripts] nova = "nova.cli:main"` (argparse-only,
|
||||||
|
no click/typer — repo convention). `nova/cli.py` auto-discovers
|
||||||
|
`nova/<module>.py` subcommands via `pkgutil.iter_modules`, dispatches,
|
||||||
|
emits the `cli.invocation` audit event (INV-12) with `mode`,
|
||||||
|
`selection_reason`, `credential_type`, `command`, `args`.
|
||||||
|
- Each `nova/<module>.py` is ≤50 lines, delegates to `core/` (CAP-034
|
||||||
|
AST scan). Subgroups: `nova auth login/revoke/status`, `nova idp
|
||||||
|
setup --check/--apply/--verify`, `nova init`, `nova apply --local`.
|
||||||
|
- `core/mode_resolver.py` — flag → env (`NOVA_CLIENT_MODE`) → credential
|
||||||
|
type → `sys.stdin.isatty()` (D-226). CLI-only; Lambdas don't resolve
|
||||||
|
modes. Property-tested with `hypothesis` (REQ-349).
|
||||||
|
- `core/env.py:+synthesize_local_env()` — synthesizes a local env dict
|
||||||
|
from a contract + `--local` flag (REQ-330). No cloud provisioning.
|
||||||
|
|
||||||
|
**Packaging (NFR-6, CAP-035):**
|
||||||
|
|
||||||
|
- CI publishes a wheel to CodeArtifact AND a Lambda layer with identical
|
||||||
|
version strings on every merge affecting `core/`/`adapters/`/`nova/`.
|
||||||
|
Version mapping recorded in SSM `/nova/layer/nova-cli/version`.
|
||||||
|
If either publish fails, the merge is blocked (REQ-323).
|
||||||
|
- `nova cli-action` composite action at
|
||||||
|
`.github/actions/nova-cli/action.yml`, referenced by both GitHub +
|
||||||
|
Gitea (`uses: continuous-intelligence/acdl/.github/actions/nova-cli@v1.28`).
|
||||||
|
Python 3.12 pinned. Byte-identical behavior verified by CI matrix
|
||||||
|
(REQ-326, NFR-11).
|
||||||
|
|
||||||
|
**Data flows:**
|
||||||
|
|
||||||
|
1. Sign-up → `nova-idp-auth` → Argon2id → `nova-users` PutItem → session
|
||||||
|
→ `nova-sessions` PutItem → return session token.
|
||||||
|
2. Token vend (hot path) → `nova-idp-token-vend` → `nova-pats` strong
|
||||||
|
read (revocation) → kyverno-json ABAC eval → if allow → KMS sign →
|
||||||
|
DER→raw → return OIDC JWT. Audit at every step.
|
||||||
|
3. JWKS fetch → `nova-idp-jwks` → `kms.get_public_key` → DER→JWK →
|
||||||
|
`{"keys":[...]}`. Cached 1h at CloudFront (if custom domain) / client.
|
||||||
|
4. PAT revoke → `nova auth revoke --pat <jti>` → `nova-pats.UpdateItem(
|
||||||
|
status=revoked)` → audit. Strong read on next vend → 403 (within 60s).
|
||||||
|
|
||||||
|
**`nova idp setup` (REQ-340, NFR-10):** generates a CloudFormation
|
||||||
|
template (raw dict → JSON, no troposphere dep), presents for review
|
||||||
|
(`$PAGER` + resource summary), requires explicit `y/N` approval before
|
||||||
|
`cloudformation deploy --capabilities CAPABILITY_IAM`. `--check` reports
|
||||||
|
prerequisites + IAM policy delta; `--verify` runs the KMS round-trip
|
||||||
|
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.
|
||||||
+22
-28
@@ -1,33 +1,27 @@
|
|||||||
{
|
{
|
||||||
"phase": 5,
|
"phase": 0,
|
||||||
"stage": "complete",
|
"stage": "mvp_ux_check",
|
||||||
"milestone": "v1.25",
|
"milestone": "v1.30",
|
||||||
"phase_role": "final",
|
"phase_role": "pre_execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-12T18:00:00Z",
|
"updated_at": "2026-08-20T13:52:00Z",
|
||||||
"project": "acdl",
|
"project": "acdl",
|
||||||
"milestone_complete": true,
|
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||||
"tag_line": "v1.24.x",
|
"active_milestone": "v1.30",
|
||||||
"tag": "v1.24.5",
|
"milestone_branch": "milestone/v1.30-leadership-deck",
|
||||||
"release": {
|
"phase_branch": "phase/00-pre-execution",
|
||||||
"forge": "gitea",
|
"tag_line": "v1.29.x",
|
||||||
"releases_created": true,
|
"phase_name": "pre-execution",
|
||||||
"release_ids": {
|
"milestone_type": "feature",
|
||||||
"v1.24.0": 640,
|
"reqs_covered": [],
|
||||||
"v1.24.1": 641,
|
"reqs_partial": [],
|
||||||
"v1.24.2": 642,
|
"previous_milestone": {
|
||||||
"v1.24.3": 643,
|
"milestone": "v1.29",
|
||||||
"v1.24.4": 644,
|
"tag": "v1.28.6",
|
||||||
"v1.24.5": 645
|
"complete": true,
|
||||||
},
|
"merged_to_main": "9dc5669",
|
||||||
"milestone_release_id": 645,
|
"branches_deleted": true,
|
||||||
"milestone_release_tag": "v1.24.5"
|
"releases_created": true
|
||||||
},
|
},
|
||||||
"requirements": ["REQ-291", "REQ-292", "REQ-293", "REQ-294", "REQ-295", "REQ-296", "REQ-297", "REQ-298", "REQ-299", "REQ-300", "REQ-301", "REQ-302", "REQ-303", "REQ-304", "REQ-305", "REQ-306", "REQ-307", "REQ-308", "REQ-309"],
|
"notes": "v1.30 Phase 0 MVP/UX CHECK passed. PLAN.md has all 3 sections: User-Facing Surface (PPTX+source+smoke test), Happy Path (J1), UX Acceptance Criteria (8 items). Auto-generated at full autonomy. Next: PHASE 0 SHIP."
|
||||||
"requirements_covered": 19,
|
|
||||||
"requirements_partial": 0,
|
|
||||||
"tests": {"total": 170, "passed": 170, "skipped": 23, "failed": 0, "preexisting_flaky": "test_metrics_emitters.py::test_attestation_event_emission (fails on main, unrelated to v1.25)"},
|
|
||||||
"phases": {"P0": "complete", "P1": "complete", "P2": "complete", "P3": "complete", "P4": "complete", "P5": "complete"},
|
|
||||||
"review": {"p0_fixed": 1, "p1_fixed": 3, "p1_flagged_posthoc": 2, "escalations": 0},
|
|
||||||
"notes": "v1.25 milestone complete. Tag v1.24.5 (milestone release, gitea id 645). 19 requirements complete (REQ-291..309). 6 phases. 170 tests pass (23 skip-without-kj). kyverno-json is the primary policy engine behind a swappable PolicyEngine adapter. Merged milestone/v1.25-kyverno-json to main. All milestone branches deleted. Next milestone starts fresh."
|
|
||||||
}
|
}
|
||||||
+191
-142
@@ -1,164 +1,213 @@
|
|||||||
# CLARIFY — v1.25 kyverno-json Unified Policy Engine
|
# CLARIFY — v1.30 Single-shot Leadership Deck
|
||||||
|
|
||||||
> **Autonomy:** full. Ambiguities are auto-resolved with assumption logging
|
> **Autonomy:** full. Auto-resolution with assumption logging per
|
||||||
> per `config.json autonomy.level: "full"` and
|
> `config.autonomy.level: "full"`. No human escalation unless
|
||||||
> `autonomy.decision_confidence_threshold: 0.6`. No human escalation.
|
> confidence < 0.60. The user confirmed the 4 framing decisions
|
||||||
|
> (milestone numbering, render pipeline path, stale intake
|
||||||
|
> assumption override, smoke test PPTX gate) in the pre-run planning
|
||||||
|
> conversation. This file records the formal D-IDs and the spec §7
|
||||||
|
> open-question resolutions.
|
||||||
|
|
||||||
## Ambiguities Identified
|
---
|
||||||
|
|
||||||
### A1 — kyverno-json install path (pip / go install / pinned binary release)
|
## Method
|
||||||
|
|
||||||
**Ambiguity:** kyverno-json is a Go project, not a Python package. Three
|
The clarify stage identifies ambiguities in the v1.30 specification
|
||||||
install paths exist: (a) `pip install` — not possible (no PyPI package);
|
(REQ-372 v1.0, locked 2026-08-20) and resolves them at full autonomy.
|
||||||
(b) `go install github.com/kyverno/kyverno-json/cmd/kj@latest` — requires
|
The spec is the user-provided "REQ-372 — Nova Leadership Presentation
|
||||||
Go toolchain in the CI image; (c) download a pinned binary release from
|
Deck." Each ambiguity gets a decision ID (D-241+, continuing from
|
||||||
GitHub releases — no Go toolchain needed, but release artifacts are
|
v1.29's D-232..D-240), a resolution, a confidence score, and a
|
||||||
platform-specific and must be checksummed.
|
rationale.
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.85):** `go install` (option b). A
|
---
|
||||||
`scripts/install-kyverno-json.sh` helper runs
|
|
||||||
`go install github.com/kyverno/kyverno-json/cmd/kj@latest` and prints
|
|
||||||
`kj version`. The CI image (`.github/workflows/ci.yml` +
|
|
||||||
`.gitea/workflows/ci.yml`) installs Go + kj when
|
|
||||||
`config.json.policy.engine == "kyverno-json"`; the install is cached via
|
|
||||||
the existing Go module cache. Rationale: `go install` is the upstream-
|
|
||||||
blessed path, tracks the latest stable release, avoids per-platform
|
|
||||||
binary management, and the project already accepts Go-based tooling
|
|
||||||
(checkov pulls Go-built transitive deps via pip). When `which kj` is
|
|
||||||
absent, `KyvernoJsonEngine.is_configured()` returns false → `SKIPPED`
|
|
||||||
PCR (mirrors the Wiz adapter pattern) — the platform functions without
|
|
||||||
the binary. Captured in REQ-293, REQ-294. Decision ID: D-115.
|
|
||||||
|
|
||||||
### A2 — `engine` enum value: new `"kyverno-json"` vs reuse `"kyverno"`
|
## Prior-conversation resolutions (already locked, restated for the record)
|
||||||
|
|
||||||
**Ambiguity:** `schemas/policy_check_result.schema.json` already lists
|
These were resolved by the user in the pre-run planning conversation
|
||||||
`engine: ["checkov", "kyverno", "opa", "wiz"]`. kyverno-json is a
|
that spawned v1.30. They are load-bearing for v1.30 execution.
|
||||||
distinct runtime from the K8s Kyverno admission controller, but both
|
|
||||||
are "Kyverno." Two options: (a) add a new `"kyverno-json"` enum value
|
|
||||||
— requires schema change + checkov/wiz adapter test regression check;
|
|
||||||
(b) reuse `"kyverno"` and distinguish by `ruleId` prefix.
|
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.80):** Reuse `"kyverno"` (option b).
|
### Q-M1 — The cover note/spec say "v1.29.x" but the checkpoint says v1.29 is complete and active_milestone is v1.30. What is the milestone number?
|
||||||
Adding `"kyverno-json"` would force a schema change + a test sweep for
|
|
||||||
no semantic gain — the `engine` field records the policy engine family,
|
|
||||||
not the specific binary. kyverno-json PCR records carry `engine:
|
|
||||||
"kyverno"` and `ruleId` prefixed `KJ_<policy_name>` (e.g.
|
|
||||||
`KJ_REQUIRE_TAGGING_STANDARD`), while the K8s adapter uses `KYVERNO_`
|
|
||||||
prefixes (e.g. `KYVERNO_INACTIVE_TF_STACK`). The two are distinguishable
|
|
||||||
in audit/telemetry by `ruleId` prefix and `evidence` payload shape (the
|
|
||||||
K8s adapter's evidence has `namespace`/`kind`; kyverno-json's has
|
|
||||||
`assertion`/`jmespath`). No schema change. Captured in REQ-293.
|
|
||||||
Decision ID: D-116.
|
|
||||||
|
|
||||||
### A3 — Do checkov/wiz adapters change their signatures to feed kyverno-json?
|
**Resolution:** The milestone is **v1.30**. The cover note's "v1.29.x"
|
||||||
|
is the **tag line** (per run.md branch strategy, tags run on the
|
||||||
|
previous minor's patch line: milestone v1.30 → tags v1.29.1,
|
||||||
|
v1.29.2, v1.29.3). The milestone branch is
|
||||||
|
`milestone/v1.30-leadership-deck`. v1.29 is complete (merged to main
|
||||||
|
`9dc5669`, tag `v1.28.6`).
|
||||||
|
**Confidence:** 1.0 (user-confirmed — "Milestone v1.30, tags v1.29.x").
|
||||||
|
**Decision:** n/a (milestone identity, not a D-ID).
|
||||||
|
|
||||||
**Ambiguity:** The unified-orchestrator model places kyverno-json "on
|
### Q-M2 — The cover note says `scripts/render_pptx.py docs/presentations/nova-leadership-deck.md` but render_pptx.py expects `{deck}-marp.md` naming. How to resolve?
|
||||||
top of" checkov/wiz. Two interpretations: (a) checkov/wiz now emit a
|
|
||||||
"raw findings" intermediate (not PCR) that kyverno-json meta-policies
|
|
||||||
consume — requires changing `adapt() -> list[PolicyCheckResult]` to
|
|
||||||
`adapt() -> list[RawFinding]`; (b) checkov/wiz keep emitting PCRs as
|
|
||||||
today, and the meta-policies in `adapters/kyverno-json/policies/meta/`
|
|
||||||
consume the **merged** PCR list as their payload.
|
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.90):** Option (b). The existing
|
**Resolution:** Author the source as
|
||||||
`adapt() -> list[PolicyCheckResult]` signatures are unchanged. The
|
`docs/presentations/nova-leadership-deck-marp.md` to fit the existing
|
||||||
meta-policies consume the merged PCR list (checkov + wiz + kyverno-json
|
`-marp.md` pipeline convention. Narrowly extend `render_pptx.py` to
|
||||||
plan-JSON policies) as their input payload. This preserves the
|
accept an explicit source `.md` path + `--output` filename, and to
|
||||||
`PolicyCheckResult` schema as the single inter-adapter contract
|
render a right-aligned footer textbox on every slide (python-pptx
|
||||||
(ARCHITECTURE.md §12.6), avoids a new "RawFinding" type, and means
|
does not read the Marp `footer:` directive). The output is
|
||||||
the existing checkov/wiz adapter tests pass unchanged. The meta-policy
|
`nova-leadership-deck.pptx` per spec REQ-372.2. Formalized as D-242.
|
||||||
`block-on-any-critical.json` iterates the merged list; the
|
**Confidence:** 1.0 (user-confirmed — "Author source as
|
||||||
`tagging-rules-agree.json` meta-policy cross-checks the Checkov
|
nova-leadership-deck-marp.md, extend render_pptx.py").
|
||||||
`NOVA_TAG_NAMING` result against the kyverno-json
|
**Decision:** D-242.
|
||||||
`KJ_REQUIRE_TAGGING_STANDARD` result by `resourceRef`. Captured in
|
|
||||||
REQ-303, D-117. Decision ID: D-117.
|
|
||||||
|
|
||||||
### A4 — `NOVA_TAG_NAMING` Checkov rule: rewrite as kyverno-json policy, keep, or both?
|
### Q-M3 — The post-v1.29 STATE.md intake (assumption 3) says the leadership deck "is a compression, not a rewrite" of the 23-slide citizen-developer deck. The cover note + spec explicitly forbid compression. How to handle?
|
||||||
|
|
||||||
**Ambiguity:** The Checkov custom rule
|
**Resolution:** Override the stale intake assumption. The leadership
|
||||||
`adapters/terraform/policy/custom_rules/nova_tagging.py` enforces the
|
deck is a **discrete, hand-authored artifact** — NOT a compression.
|
||||||
Nova tagging standard over Terraform HCL (static scan + plan scan). The
|
The existing citizen-developer deck
|
||||||
kyverno-json milestone adds `require-tagging-standard.json` over the
|
(`nova-autonomous-cloud-delivery-marp.md`) remains untouched. The
|
||||||
resolved Stack IR. Three options: (a) rewrite — replace the Checkov
|
spec §2.2 + cover note forbid compression/mirroring; the Slide
|
||||||
rule with the kyverno-json policy (loses Checkov's HCL-level coverage
|
Content Map is hand-authored content, not derived. Update STATE.md
|
||||||
and the `--external-checks-dir` integration); (b) keep Checkov only —
|
intake assumption 3 to reflect the discrete-artifact decision.
|
||||||
don't add a kyverno-json policy (the Stack IR is already the input to
|
Formalized as D-241.
|
||||||
terraform, so the Checkov rule catches it); (c) both — keep the
|
**Confidence:** 1.0 (user-confirmed — "Override with spec's
|
||||||
Checkov rule as the source of truth for HCL-level scanning AND add the
|
discrete-artifact decision").
|
||||||
kyverno-json policy for IR-level coverage, with a meta-policy that
|
**Decision:** D-241.
|
||||||
asserts the two agree.
|
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.82):** Option (c) — both, with a
|
### Q-M4 — The smoke test (REQ-372.8f) must assert PPTX file existence. Given the render environment limitations, should the PPTX-existence check be a hard fail or a conditional skip?
|
||||||
cross-check meta-policy. The Checkov rule stays the source of truth
|
|
||||||
for `terraform_plan` scanning (it reads HCL resource blocks directly);
|
|
||||||
the kyverno-json policy covers the Stack IR dict (which is the input
|
|
||||||
*before* terraform, so it catches IR-level violations that the
|
|
||||||
terraform adapter might mask via defaults). The P3 meta-policy
|
|
||||||
`tagging-rules-agree.json` asserts the two engines agree on every
|
|
||||||
resource; divergence emits an `error` PCR (defense-in-depth against
|
|
||||||
rule drift — if the two engines disagree, the operator must
|
|
||||||
investigate before proceeding). This is the only case in v1.25 where
|
|
||||||
two engines evaluate the same concern; it is intentional — the
|
|
||||||
tagging standard is the highest-impact rule (v1.8 D-tagging-standard,
|
|
||||||
v1.10 re-verification) and merits redundancy. Captured in REQ-297,
|
|
||||||
REQ-303, REQ-299. Decision ID: D-118.
|
|
||||||
|
|
||||||
### A5 — Critical-override: delegate to declarative meta-policy or keep hard-override?
|
**Resolution:** **Hard fail** if `.pptx` absent. The deck must be
|
||||||
|
rendered before ship. The render environment is resolved (python-pptx
|
||||||
|
installed via user-site `pip install --user --break-system-packages`;
|
||||||
|
no Chromium needed since python-pptx is the render path, not Marp
|
||||||
|
CLI). If the environment cannot render, that is a ship blocker to
|
||||||
|
resolve — not a reason to weaken the gate.
|
||||||
|
**Confidence:** 1.0 (user-confirmed — "Hard fail if .pptx absent").
|
||||||
|
**Decision:** n/a (gate severity, not a D-ID — recorded in PLAN.md).
|
||||||
|
|
||||||
**Ambiguity:** `core/confidence_signal.py` lines 144-157 hardcode
|
---
|
||||||
`PENALTY["critical"]: None` — a critical-severity `fail` PCR forces
|
|
||||||
`score = 0, band = block` regardless of the weighted-sum inputs. The
|
|
||||||
v1.25 meta-policy `block-on-any-critical.json` makes this declarative
|
|
||||||
(asserts no PCR in the merged list has `severity: critical` +
|
|
||||||
`result: fail`). Two options: (a) fully delegate — remove the
|
|
||||||
hard-override, rely on the meta-policy to emit a critical `fail` PCR
|
|
||||||
that the existing penalty logic then blocks; (b) keep both — the
|
|
||||||
meta-policy is the declarative source of truth, the hard-override is
|
|
||||||
defense-in-depth.
|
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.88):** Option (b) — keep both. The
|
## Open questions from the spec's §7 (auto-resolved at full autonomy)
|
||||||
meta-policy is the *declarative* statement ("Nova blocks on any
|
|
||||||
critical finding from any engine"); the hard-override is the
|
|
||||||
*imperative* safety net that ensures a critical PCR can never slip
|
|
||||||
through even if the meta-policy is misconfigured or the
|
|
||||||
`PolicyEngineRegistry` returns a `NullEngine`. This is
|
|
||||||
defense-in-depth, not redundancy-for-its-own-sake: the meta-policy
|
|
||||||
runs *before* the confidence signal (it produces PCRs that flow in),
|
|
||||||
the hard-override runs *inside* the confidence signal (it is the last
|
|
||||||
gate). Removing the hard-override would make the platform's
|
|
||||||
"critical = block" guarantee depend on a single declarative policy
|
|
||||||
file — a regression in the provable-trust posture (Strategic
|
|
||||||
Objective #2). Captured in REQ-303, PROJECT.md hard-constraints.
|
|
||||||
Decision ID: D-119.
|
|
||||||
|
|
||||||
### A6 — Does kyverno-json break the "platform functions without AI" tenet?
|
### Q1 — Specific meeting date inside August 2026
|
||||||
|
|
||||||
**Ambiguity:** NORTH_STAR.md Strategic Objective #2: "the platform
|
**Spec context:** The presentation is in August 2026, but no specific
|
||||||
functions without AI — 'AI decisions' are really automated decisions."
|
day is named. Slide 7 references "Infrastructure & Operations
|
||||||
kyverno-json is a deterministic policy engine (no ML), but it is a new
|
leadership" without naming a day.
|
||||||
runtime dependency. Does adding it violate the tenet?
|
|
||||||
|
|
||||||
**Resolution (auto, confidence 0.95):** No — kyverno-json is
|
**Resolution:** Anchor to **month-only** (August 2026). No specific
|
||||||
deterministic, not AI. The tenet distinguishes "AI decisions" (LLM-
|
day in the deck text. November 2026 is the runway anchor (~90 days
|
||||||
driven, non-reproducible) from "automated decisions" (rule-driven,
|
from August 2026).
|
||||||
reproducible). kyverno-json is the latter — the same policy + payload
|
**Confidence:** 0.95. **Impact if wrong:** Very low — the meeting is
|
||||||
produces the same result on every run. It is *more* aligned with the
|
what it is; the deck text doesn't depend on a specific day.
|
||||||
tenet than the current imperative Python in `core/env_transition.py`
|
**Decision:** D-243 (date anchor discipline: month-only).
|
||||||
and `core/regression_verify.py`, because the policy is declarative
|
|
||||||
(visible, auditable, version-controlled) rather than imperative (logic
|
|
||||||
hidden in function bodies). The `is_configured()` guard ensures the
|
|
||||||
platform functions without the binary (graceful skip), so the tenet
|
|
||||||
holds even in environments where kyverno-json is not installed.
|
|
||||||
Captured in PROJECT.md hard-constraints + RESEARCH.md G-Q1.
|
|
||||||
Decision ID: D-120.
|
|
||||||
|
|
||||||
## Summary
|
### Q2 — Explicit non-compression of the existing citizen-developer deck
|
||||||
|
|
||||||
6 ambiguities identified; 6 auto-resolved at full autonomy (no human
|
**Spec context:** The two decks (leadership + citizen-developer)
|
||||||
escalation). All resolutions are binding and recorded as D-115..D-120.
|
remain discrete artifacts. The existing 23-slide
|
||||||
The resolutions are captured in PROJECT.md hard-constraints,
|
`nova-autonomous-cloud-delivery-marp.md` is not compressed or
|
||||||
REQUIREMENTS.md v1.25 sections, and will be referenced in RESEARCH.md +
|
modified.
|
||||||
PLAN.md. No PROJECT.md or REQUIREMENTS.md structural changes beyond the
|
|
||||||
v1.25 sections added in SPECIFY — the resolutions are already embedded
|
**Resolution:** Document the discrete-artifact constraint in
|
||||||
in the requirement text (REQ-293, REQ-297, REQ-303, etc.) via the
|
REQ-372.9 (related-artifacts header comment) + D-241 (this file) +
|
||||||
"Decision" annotations.
|
D-241 record in PROJECT.md at ship (REQ-372.11). Leave the existing
|
||||||
|
citizen-developer deck untouched. The cover note's hard scope rules
|
||||||
|
("Do not modify `nova-autonomous-cloud-delivery-marp.md`") are
|
||||||
|
binding.
|
||||||
|
**Confidence:** 1.0. **Impact if wrong:** None for this milestone.
|
||||||
|
**Decision:** D-241 (restated — the discrete-artifact decision is the
|
||||||
|
same as Q-M3's override).
|
||||||
|
|
||||||
|
### Q3 — Assumption: existing `scripts/render_pptx.py` accepts S&P theme directives and Marp speaker notes without modification
|
||||||
|
|
||||||
|
**Spec context:** The render pipeline is existing; the spec assumes
|
||||||
|
it works for the new deck. If a theme limitation forces a renderer
|
||||||
|
change, scope narrowly and update `render_pptx.py` separately as a
|
||||||
|
non-REQ-372 task.
|
||||||
|
|
||||||
|
**Resolution (confirmed by research):** The existing `render_pptx.py`
|
||||||
|
has two gaps for REQ-372: (a) it expects a `{deck}` arg and reads
|
||||||
|
`{deck}-marp.md` / writes `{deck}-python.pptx` — it does not accept
|
||||||
|
an explicit source path or custom output filename; (b) it does not
|
||||||
|
read the Marp `footer:` directive (it skips HTML comments at lines
|
||||||
|
366-379 and never adds a footer textbox). Speaker notes (HTML
|
||||||
|
comments) are skipped entirely — acceptable for REQ-372.4 (smoke test
|
||||||
|
checks source word counts, not PPTX-embedded notes). The narrow
|
||||||
|
extension per D-242 addresses (a) and (b). No other renderer change
|
||||||
|
is needed. The extension is a prerequisite, scoped separately from
|
||||||
|
REQ-372 per spec §3.3 Edge 2.
|
||||||
|
**Confidence:** 0.92. **Impact if wrong:** Small follow-up; doesn't
|
||||||
|
change milestone scope.
|
||||||
|
**Decision:** D-242 (restated).
|
||||||
|
|
||||||
|
### Q4 — Assumption: the 18-month runway shape (α–δ) is acceptable as drafted to I&O leadership
|
||||||
|
|
||||||
|
**Spec context:** Slides 6 + 7 rehearse both architecture-load and
|
||||||
|
political-cover framings. The worked-example granularity was
|
||||||
|
confirmed by the PO.
|
||||||
|
|
||||||
|
**Resolution:** Accept the α–δ shape as drafted. Slides 6 + 7 are the
|
||||||
|
only slide-by-slide revisions that might be needed if leadership
|
||||||
|
pushes back; everything else is locked. No spec change required
|
||||||
|
unless the architectural claim set shifts (spec §3.3 Edge 3).
|
||||||
|
**Confidence:** 0.85. **Impact if wrong:** Slide 6 and slide 7 are
|
||||||
|
the only revisions; everything else is locked.
|
||||||
|
**Decision:** n/a (acceptance, not a D-ID — the shape is in the
|
||||||
|
locked Slide Content Map).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions (locked, full autonomy — load-bearing for v1.30)
|
||||||
|
|
||||||
|
### D-241 — Leadership deck is discrete, hand-authored, NOT a compression
|
||||||
|
|
||||||
|
**Q-M3 / Q2.** The leadership deck is a **discrete, hand-authored
|
||||||
|
artifact** — NOT a compression of the existing 23-slide
|
||||||
|
citizen-developer pitch
|
||||||
|
(`nova-autonomous-cloud-delivery-marp.md`). This overrides the
|
||||||
|
post-v1.29 STATE.md intake assumption 3 ("is a compression, not a
|
||||||
|
rewrite"). The existing citizen-developer deck remains untouched.
|
||||||
|
The spec §2.2 + cover note forbid compression/mirroring; the Slide
|
||||||
|
Content Map is hand-authored content, not derived. Recorded in
|
||||||
|
PROJECT.md at ship (REQ-372.11).
|
||||||
|
**Confidence:** 1.0.
|
||||||
|
|
||||||
|
### D-242 — Narrow render_pptx.py extension (path arg + custom output + footer textbox)
|
||||||
|
|
||||||
|
**Q-M2 / Q3.** The existing `scripts/render_pptx.py` is narrowly
|
||||||
|
extended to: (a) accept an explicit source `.md` path + `--output`
|
||||||
|
filename (honouring the cover note's invocation pattern), and (b)
|
||||||
|
render a right-aligned footer textbox on every slide with the exact
|
||||||
|
string `Nova Platform - Infrastructure & Operations` (python-pptx
|
||||||
|
does not read the Marp `footer:` directive; REQ-372.5 requires the
|
||||||
|
footer on every rendered slide). The source is authored as
|
||||||
|
`nova-leadership-deck-marp.md` to fit the existing `-marp.md`
|
||||||
|
pipeline convention; the output is `nova-leadership-deck.pptx` per
|
||||||
|
spec REQ-372.2. This extension is a non-REQ-372 prerequisite per
|
||||||
|
spec §3.3 Edge 2 ("scope narrowly and update `render_pptx.py`
|
||||||
|
separately"). No other renderer change (speaker notes are not
|
||||||
|
embedded in the PPTX — acceptable; smoke test checks source word
|
||||||
|
counts).
|
||||||
|
**Confidence:** 0.92.
|
||||||
|
|
||||||
|
### D-243 — Date anchor discipline: month-only (August 2026 present, November 2026 runway)
|
||||||
|
|
||||||
|
**Q1.** August 2026 is a **month-only** presentation anchor (no
|
||||||
|
specific day). November 2026 is the runway anchor (~90 days from
|
||||||
|
August 2026). Slide 7 references "Infrastructure & Operations
|
||||||
|
leadership" without naming a specific day. No spec change required
|
||||||
|
unless the architectural claim set shifts (spec §3.3 Edge 3).
|
||||||
|
**Confidence:** 0.95.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STATE.md intake assumption override
|
||||||
|
|
||||||
|
The post-v1.29 STATE.md intake (line ~526, Agent Assumptions, item 3)
|
||||||
|
states: "The 23-slide existing deck is the source material — the
|
||||||
|
≤7-slide leadership deck is a compression, not a rewrite."
|
||||||
|
|
||||||
|
**Override (D-241):** This assumption is **replaced**. The leadership
|
||||||
|
deck is a discrete, hand-authored artifact — NOT a compression. The
|
||||||
|
existing citizen-developer deck remains untouched. The override is
|
||||||
|
recorded in this CLARIFY.md (D-241) and will be reflected in STATE.md
|
||||||
|
at the v1.30 ship wave (CAP-042 row + intake assumption correction).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements impact
|
||||||
|
|
||||||
|
No requirements are added, removed, or re-scoped by these decisions.
|
||||||
|
D-241–D-243 are load-bearing context for executing REQ-372.1–.12 as
|
||||||
|
written. The spec is locked (v1.0, 2026-08-20); no spec text changes.
|
||||||
+127
-205
@@ -1,216 +1,138 @@
|
|||||||
# GRILL — v1.25 kyverno-json Unified Policy Engine
|
# GRILL — v1.30 Single-shot Leadership Deck
|
||||||
|
|
||||||
> Adversarial review of the v1.25 SPECIFY + CLARIFY + RESEARCH + IDEATE +
|
> Adversarial review of the v1.30 SPECIFY + CLARIFY + RESEARCH +
|
||||||
> PLAN. The grill red-teams the proposal across feasibility, scope,
|
> PLAN. Griller: lead-developer (acting as ci-griller at full
|
||||||
> budget, and the swap-boundary claim. Each challenge gets a binding
|
> autonomy). All 9 axes reviewed; every claim verified against the
|
||||||
> verdict (PROCEED / REVISE / ESCALATE). Autonomy: full — escalations
|
> live codebase.
|
||||||
> auto-resolve with assumption logging unless confidence < 0.60.
|
|
||||||
|
|
||||||
## Verdict: PROCEED (0.86) — 0 escalations, 2 revisions
|
|
||||||
|
|
||||||
The milestone is feasible, scoped, and the swap boundary is real. Two
|
|
||||||
plan revisions are binding (G-Q4, G-Q8) and are already captured in
|
|
||||||
PLAN.md. No work is blocked.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Challenges
|
## Overall verdict: **PROCEED** · Confidence 0.88
|
||||||
|
|
||||||
### G-Q1 — Does kyverno-json violate "platform functions without AI"?
|
The plan is sound — this is a low-complexity, single-shot
|
||||||
|
presentation artifact milestone. The scope is narrow (1 execution
|
||||||
|
phase, 4 waves, no runtime code), the render pipeline extension is
|
||||||
|
minimal (D-242), the discrete-artifact discipline is clear (D-241),
|
||||||
|
and the environment prerequisites are resolved (python-pptx
|
||||||
|
installed). No critical conditions. 4 tracked conditions (all
|
||||||
|
advisory, none block P1).
|
||||||
|
|
||||||
**Challenge:** NORTH_STAR.md Strategic Objective #2 says "the platform
|
The lower confidence vs. a "clean 0.95" reflects two residual
|
||||||
functions without AI." kyverno-json is a new runtime dependency. Is
|
risks: (1) the python-pptx user-site install is environment-fragile
|
||||||
this a real violation, or is the tenet about LLMs (not deterministic
|
(it works now but is not reproducible in a fresh CI runner without
|
||||||
engines)?
|
the same `--break-system-packages` path); (2) the PPTX footer
|
||||||
|
textbox is a new renderer behavior that needs visual confirmation.
|
||||||
**Verdict:** PROCEED (confidence 0.95). kyverno-json is deterministic
|
Both are mitigated — (1) by the smoke-test hard-fail gate (8f) which
|
||||||
(same policy + payload → same result, every run). The tenet
|
forces render success before ship, and (2) by the verify stage's
|
||||||
distinguishes AI (non-reproducible) from automation (reproducible).
|
visual review (REQ-372.7).
|
||||||
kyverno-json is the latter — and is *more* aligned than the imperative
|
|
||||||
Python it replaces (`core/env_transition.py`, `core/regression_verify.py`)
|
|
||||||
because the policy is declarative (visible, auditable). The
|
|
||||||
`is_configured()` guard ensures the platform runs without the binary.
|
|
||||||
Already resolved as D-120 in CLARIFY. No revision needed.
|
|
||||||
|
|
||||||
### G-Q2 — Is the PolicyEngine protocol over-engineered for a 2-engine future?
|
|
||||||
|
|
||||||
**Challenge:** The user asked for a swappable adapter ("we might one
|
|
||||||
day decide to replace it with something else like OPA"). A Python
|
|
||||||
Protocol + registry is ~40 lines. But Nova has 1 engine today. Is this
|
|
||||||
premature abstraction?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.85). The user *explicitly* asked for
|
|
||||||
the swap boundary — this is not speculative abstraction, it's a
|
|
||||||
stated requirement. The protocol is minimal (3 methods) and the OPA-
|
|
||||||
equivalent surface is documented (RESEARCH §4.2) — the swap is a known
|
|
||||||
quantity, not a hope. The cost is ~40 lines of Python + a config key;
|
|
||||||
the benefit is a documented, tested swap boundary that a future
|
|
||||||
milestone implements without re-architecting. This is the moat (NORTH
|
|
||||||
STAR Objective #2 — provable trust via a replaceable substrate, not a
|
|
||||||
vendor lock-in).
|
|
||||||
|
|
||||||
### G-Q3 — Does wrapping checkov findings in kyverno-json meta-policies break the MTTR < 60s target?
|
|
||||||
|
|
||||||
**Challenge:** NORTH_STAR.md MTTR target: < 60s p95. Adding a second
|
|
||||||
engine pass over the terraform plan + a meta-policy pass over the
|
|
||||||
merged PCR list adds latency. Does this break the target?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.88). RESEARCH §5 analyzes: the kj
|
|
||||||
pass over plan JSON is < 1s (Go binary startup + JMESPath over a small
|
|
||||||
plan); it runs **in parallel** with Checkov (REQ-301), so wall-clock
|
|
||||||
impact is `max(checkov_time, kj_time)` ≈ checkov_time. Meta-policies
|
|
||||||
run in-memory over the merged list (< 10ms). Total MTTR impact: < 1s
|
|
||||||
on a 5-15s step. **Binding revision (G-Q3a):** P3 VERIFY must include a
|
|
||||||
timing assertion — `run_platform.sh` Step 5 wall-clock with vs without
|
|
||||||
kj must be within 1s (or kj must be faster than checkov, which is
|
|
||||||
expected). Captured as a P3 verify gate, not a PLAN change.
|
|
||||||
|
|
||||||
### G-Q4 — Plan revision: NullEngine fallback may mask misconfiguration
|
|
||||||
|
|
||||||
**Challenge:** PLAN.md P1 says "existing tests pass (NullEngine
|
|
||||||
fallback when `policy` key absent in test config)." But the v1.25
|
|
||||||
config.json *sets* the `policy` key. So existing tests that load the
|
|
||||||
real config get `KyvernoJsonEngine` with `is_configured()==false` →
|
|
||||||
`SKIPPED`. The NullEngine fallback only triggers when the key is
|
|
||||||
*absent*. Is there a gap where a test expects `NullEngine` but gets
|
|
||||||
`KyvernoJsonEngine` (skipped)?
|
|
||||||
|
|
||||||
**Verdict:** REVISE (confidence 0.82). The fallback path is correct
|
|
||||||
but the PLAN wording is ambiguous. **Binding revision:** P1 must
|
|
||||||
explicitly test *both* paths: (a) `policy` key absent → `NullEngine`
|
|
||||||
→ `SKIPPED` PCR; (b) `policy` key present + `which kj` false →
|
|
||||||
`KyvernoJsonEngine` → `is_configured()==false` → `SKIPPED` PCR with
|
|
||||||
`KJ_ENGINE_NOT_CONFIGURED` (distinct from NullEngine's
|
|
||||||
`NULL_ENGINE_INACTIVE`). The two `SKIPPED` PCRs have different
|
|
||||||
`ruleId`s so audit can distinguish "policy disabled" from "engine not
|
|
||||||
installed." PLAN.md P1 verification is amended to assert both paths.
|
|
||||||
Already reflected in REQ-291 (NullEngine) + REQ-293
|
|
||||||
(`KJ_ENGINE_NOT_CONFIGURED`). No requirement change — PLAN wording
|
|
||||||
clarified.
|
|
||||||
|
|
||||||
### G-Q5 — Policy explosion: 4 targets × N rules = maintenance load
|
|
||||||
|
|
||||||
**Challenge:** v1.25 adds ~13 policy files (4 contract + 3 stack-IR +
|
|
||||||
3 plan-JSON + 2 meta + 3 regression + 1 smoke). Each is a YAML file
|
|
||||||
with JMESPath. Is this a maintenance burden that grows unbounded?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.80). 13 policies is manageable —
|
|
||||||
each is < 30 lines of YAML, co-located per target dir, and the meta-
|
|
||||||
policy cross-check (`tagging-rules-agree`) keeps the set auditable.
|
|
||||||
The growth rate is bounded by the module count (module owners author
|
|
||||||
per-module policies, documented in P4 STANDARDS.md). The alternative
|
|
||||||
(imperative Python in `regression_verify.py` + `env_transition.py`) is
|
|
||||||
*less* auditable — the policies are a net improvement. No revision.
|
|
||||||
|
|
||||||
### G-Q6 — The tagging cross-check (D-118) is the only redundant rule — is it worth the complexity?
|
|
||||||
|
|
||||||
**Challenge:** D-118 keeps `NOVA_TAG_NAMING` (Checkov) AND adds
|
|
||||||
`KJ_REQUIRE_TAGGING_STANDARD` (kyverno-json) with a `tagging-rules-agree`
|
|
||||||
meta-policy. This is the only case where two engines evaluate the same
|
|
||||||
concern. Is the defense-in-depth worth the complexity?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.82). The tagging standard is the
|
|
||||||
highest-impact rule (v1.8 D-tagging-standard, v1.10 re-verification —
|
|
||||||
the rule that gates every resource). Redundancy here is intentional:
|
|
||||||
the Checkov rule catches HCL-level violations; the kj policy catches
|
|
||||||
IR-level violations (before terraform runs); the meta-policy catches
|
|
||||||
engine drift. The cost is 2 policy files + 1 meta-policy; the benefit
|
|
||||||
is that a tagging violation can't slip through a single engine's
|
|
||||||
blind spot. This is the textbook defense-in-depth case. No revision.
|
|
||||||
|
|
||||||
### G-Q7 — Can `kj scan` actually evaluate the merged PCR list as a payload?
|
|
||||||
|
|
||||||
**Challenge:** The meta-policies (REQ-303) consume the merged
|
|
||||||
`list[PolicyCheckResult]` as their payload. `kj scan` expects a JSON/
|
|
||||||
YAML *file*. Is the PCR list a valid kyverno-json payload shape?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.85). The PCR list is a JSON array
|
|
||||||
of objects — a valid kyverno-json payload. The `~` modifier iterates
|
|
||||||
the array; JMESPath asserts over each PCR's `severity`/`result`/
|
|
||||||
`ruleId`/`resourceRef` fields. The engine writes the list to a temp
|
|
||||||
JSON file and invokes `kj scan --payload <file>`. This is verified in
|
|
||||||
P3 `test_meta_policies.py`. No revision — but **binding note (G-Q7a):**
|
|
||||||
the `KyvernoJsonEngine.evaluate()` must accept a `list[dict]` payload
|
|
||||||
(not just a `dict`) — the `payload: dict | str` signature in RESEARCH
|
|
||||||
§4.1 is too narrow. **Revision:** the protocol signature is
|
|
||||||
`payload: dict | list | str` (a list is a valid payload for meta-
|
|
||||||
policies). Captured in REQ-291 + REQ-293 (the engine writes whatever
|
|
||||||
JSON-serializable payload it receives to the temp file). PLAN.md P1
|
|
||||||
amended.
|
|
||||||
|
|
||||||
### G-Q8 — Plan revision: the OPA swap surface claims (RESEARCH §4.2) are unverified
|
|
||||||
|
|
||||||
**Challenge:** RESEARCH §4.2 documents the OPA-equivalent surface
|
|
||||||
(`opa eval -d <dir> -i <json>`), but no `OpaEngine` is implemented in
|
|
||||||
v1.25. Is the swap-boundary claim testable, or is it aspirational?
|
|
||||||
|
|
||||||
**Verdict:** REVISE (confidence 0.78). The swap-boundary claim is
|
|
||||||
*testable in v1.25* without implementing OPA: the `PolicyEngine`
|
|
||||||
Protocol + registry is the contract; the `NullEngine` proves a second
|
|
||||||
implementation exists (structural conformance). **Binding revision
|
|
||||||
(G-Q8a):** P1 `test_policy_engine.py` must include a
|
|
||||||
`test_protocol_conformance_null_engine` that asserts `NullEngine`
|
|
||||||
satisfies the `PolicyEngine` Protocol (via
|
|
||||||
`isinstance(NullEngine(), PolicyEngine)` under `runtime_checkable`).
|
|
||||||
This proves the protocol is *real* (a second engine implements it)
|
|
||||||
without implementing OPA. The OPA-equivalent surface in RESEARCH §4.2
|
|
||||||
stays as documentation (the future milestone implements it). PLAN.md
|
|
||||||
P1 verification amended. No requirement change — the test is already
|
|
||||||
in REQ-308 ("protocol conformance").
|
|
||||||
|
|
||||||
### G-Q9 — Budget: is 4 execution phases + P5 too many for the scope?
|
|
||||||
|
|
||||||
**Challenge:** v1.25 is 19 requirements across 6 phases. Recent
|
|
||||||
milestones: v1.24 had 15 reqs / 4 phases; v1.23 had 13 reqs / 7 phases.
|
|
||||||
Is 6 phases too many (overhead) or too few (per-phase overload)?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.85). 19 reqs / 6 phases ≈ 3.2 reqs/
|
|
||||||
phase — within the v1.24 cadence (3.75 reqs/phase). The phases are
|
|
||||||
vertical slices (each ships a working increment): P1 engine works
|
|
||||||
end-to-end with a smoke policy; P2 contract + IR policies feed the
|
|
||||||
confidence signal; P3 plan-JSON + meta + pipeline wiring; P4
|
|
||||||
regression + docs. The phase count matches the user's "3-4 phases"
|
|
||||||
selection (4 execution + 1 final = 5, which is the v1.24 shape). No
|
|
||||||
revision.
|
|
||||||
|
|
||||||
### G-Q10 — The `nova.cloudinit.dev/severity` annotation convention is unvalidated
|
|
||||||
|
|
||||||
**Challenge:** RESEARCH §2.6 declares the severity-via-annotation
|
|
||||||
convention, but kyverno-json's behavior with unknown annotations is
|
|
||||||
not verified. Does `kj scan` ignore unknown annotations, or does it
|
|
||||||
reject the policy?
|
|
||||||
|
|
||||||
**Verdict:** PROCEED (confidence 0.80). kyverno-json is Kubernetes-
|
|
||||||
style CRD-based — unknown `metadata.annotations` are preserved and
|
|
||||||
ignored (standard K8s behavior). The engine reads the annotation from
|
|
||||||
the loaded policy YAML (via `yaml.safe_load`) before invoking `kj
|
|
||||||
scan` — so even if `kj scan` stripped annotations, the engine still
|
|
||||||
has them. **Binding note (G-Q10a):** P1 `test_kyverno_json_engine.py`
|
|
||||||
must assert the severity annotation is read correctly (a policy with
|
|
||||||
`nova.cloudinit.dev/severity: high` produces PCRs with `severity:
|
|
||||||
"high"`; a policy without the annotation produces PCRs with
|
|
||||||
`severity: "info"` default). Captured in REQ-309 ("PCR schema
|
|
||||||
validity" includes severity). No requirement change — the test is
|
|
||||||
already in REQ-309.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary
|
## Axis verdicts
|
||||||
|
|
||||||
10 challenges; 10 resolved (8 PROCEED, 2 REVISE, 0 ESCALATE).
|
| Axis | Verdict | Confidence | Tracked condition |
|
||||||
- **Revisions (binding, already in PLAN/REQs):**
|
|------|---------|-----------|-------------------|
|
||||||
- G-Q4: P1 tests both fallback paths (NullEngine vs
|
| §1 Feasibility | PROCEED | 0.90 | T-1.1 footer textbox overlap |
|
||||||
KyvernoJsonEngine-not-configured) — distinct `ruleId`s for audit.
|
| §2 Scope | PROCEED | 0.92 | T-2.1 single-shot discipline enforcement |
|
||||||
- G-Q7a: protocol signature `payload: dict | list | str` (list is a
|
| §3 Cost | PROCEED | 0.95 | (none — smallest milestone in project history) |
|
||||||
valid payload for meta-policies).
|
| §4 Schedule | PROCEED | 0.93 | (none — 1 execution phase) |
|
||||||
- G-Q8a: P1 test asserts `NullEngine` satisfies the `PolicyEngine`
|
| §5 Technical Depth | PROCEED | 0.86 | T-5.1 speaker notes word-band parsing; T-5.2 `→` bullet rendering |
|
||||||
Protocol (proves the swap boundary is real without implementing OPA).
|
| §6 Operational Readiness | PROCEED | 0.90 | T-6.1 render env reproducibility |
|
||||||
- G-Q3a: P3 VERIFY includes a timing assertion (kj pass < 1s, parallel
|
| §7 Security Posture | PROCEED | 0.95 | (none — static artifact, no runtime surface) |
|
||||||
with checkov).
|
| §8 Dependency Risk | PROCEED | 0.84 | T-8.1 python-pptx user-site install |
|
||||||
- G-Q10a: P1 test asserts severity annotation is read correctly.
|
| §9 Re-mapping Integrity | PROCEED | 0.92 | T-9.1 STATE.md intake override applied |
|
||||||
- **No requirement changes** — all revisions are clarifications to
|
|
||||||
PLAN.md verification text, already supported by existing REQs
|
|
||||||
(REQ-291, REQ-293, REQ-308, REQ-309).
|
|
||||||
- **0 escalations** — all challenges auto-resolved at full autonomy.
|
|
||||||
|
|
||||||
The milestone PROCEEDs to PHASE 0 SHIP → P1.
|
---
|
||||||
|
|
||||||
|
## Tracked conditions (advisory — none block P1)
|
||||||
|
|
||||||
|
### T-1.1 — Footer textbox overlap with content
|
||||||
|
|
||||||
|
**Claim:** The footer textbox at `SLIDE_H - 0.3"` (7.2") won't
|
||||||
|
overlap content (content area tops out at ~6.5").
|
||||||
|
|
||||||
|
**Verification:** python-pptx test rendered a textbox at
|
||||||
|
`Inches(7.2)` on a 7.5" slide — succeeds, no overlap with content
|
||||||
|
ending at ~6.5". The `render_content_slide` accumulates `cur_top`
|
||||||
|
per body block; a 7-slide deck with the Slide Content Map's body
|
||||||
|
volume (titles + 3-5 body blocks per slide) tops out at ~5.5-6.0".
|
||||||
|
**Verdict:** Safe. The verify stage visual review (REQ-372.7) is the
|
||||||
|
backstop.
|
||||||
|
|
||||||
|
### T-2.1 — Single-shot discipline enforcement
|
||||||
|
|
||||||
|
**Claim:** The deck is not wired as a CI gate, not integrated with
|
||||||
|
`publish.yml`, not auto-regenerated.
|
||||||
|
|
||||||
|
**Verification:** `workflows-src/slides.yml` triggers on
|
||||||
|
`docs/presentations/**` but `render_slides.sh` hardcodes
|
||||||
|
`DECK="nova-autonomous-cloud-delivery"` — the leadership deck is
|
||||||
|
NOT rendered by CI. No `publish.yml` reference to the leadership
|
||||||
|
deck. The smoke test is a standalone script (no workflow
|
||||||
|
integration). **Verdict:** Discipline enforced by absence — no CI
|
||||||
|
plumbing references the new artifact.
|
||||||
|
|
||||||
|
### T-5.1 — Speaker notes word-band parsing in bash
|
||||||
|
|
||||||
|
**Claim:** The smoke test extracts `<!-- ... -->` per slide and
|
||||||
|
counts words with `wc -w`.
|
||||||
|
|
||||||
|
**Verification:** Bash `awk`/`sed` can extract HTML comment content
|
||||||
|
per slide (split on `---`, then extract `<!--` ... `-->` within each
|
||||||
|
slide block). Multi-line comments are supported by the spec
|
||||||
|
convention ("placed within the slide body, before the next `---`").
|
||||||
|
**Verdict:** Feasible. The ci-cli-engineer implements + tests this in
|
||||||
|
W3.
|
||||||
|
|
||||||
|
### T-5.2 — `→` bullet rendering
|
||||||
|
|
||||||
|
**Claim:** Use `- → ...` bullets so the renderer treats `→` lines as
|
||||||
|
bullet blocks.
|
||||||
|
|
||||||
|
**Verification:** The renderer's unordered-list regex
|
||||||
|
`^(\s*)([-*+])\s+(.*)` matches `- → ...` → bullet level 0, text
|
||||||
|
`→ ...`. The `→` is preserved in the rendered text. **Verdict:**
|
||||||
|
Correct. The ci-doc-writer uses `- → ...` for the arrow lines.
|
||||||
|
|
||||||
|
### T-6.1 / T-8.1 — Render environment reproducibility
|
||||||
|
|
||||||
|
**Claim:** python-pptx is installed via user-site
|
||||||
|
`pip install --user --break-system-packages`.
|
||||||
|
|
||||||
|
**Verification:** Confirmed in this session: python-pptx 1.0.2 +
|
||||||
|
pytest 9.1.1 installed. `python3 -c "import pptx"` succeeds. The
|
||||||
|
install path is environment-specific (Debian/Ubuntu without system
|
||||||
|
pip/venv). In a fresh CI runner, the `slides.yml` workflow uses
|
||||||
|
`pip install -e ".[slides]"` (system pip in the runner image) —
|
||||||
|
reproducible there. For local on-demand renders, the user-site
|
||||||
|
install is the documented path. **Verdict:** Acceptable. The
|
||||||
|
smoke-test hard-fail gate (8f) forces render success before ship;
|
||||||
|
if the environment can't render, ship blocks until resolved.
|
||||||
|
|
||||||
|
### T-9.1 — STATE.md intake override applied
|
||||||
|
|
||||||
|
**Claim:** D-241 overrides the stale STATE.md intake assumption 3.
|
||||||
|
|
||||||
|
**Verification:** STATE.md line ~526 assumption 3 was edited in
|
||||||
|
CLARIFY to read "OVERRIDDEN by D-241 (v1.30 CLARIFY): the leadership
|
||||||
|
deck is a discrete, hand-authored artifact — NOT a compression."
|
||||||
|
The override is recorded in CLARIFY.md (D-241) + this grill. **Verdict:**
|
||||||
|
Applied + verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Binding decisions (grill-level, full autonomy)
|
||||||
|
|
||||||
|
| ID | Decision | Rationale | Confidence |
|
||||||
|
|----|----------|-----------|-----------|
|
||||||
|
| G-1 | All 7 slides use `##` H2 titles (content slides, white bg) — slide 1 is NOT a title-class slide. | The Slide Content Map's slide 1 is content-rich (3 friction patterns + closing). A black-bg title slide would hide the arrows in white-on-black, differing from the map's framing. White-bg content slides give visual consistency across all 7. The map doesn't specify background; visual review accepts either. | 0.82 |
|
||||||
|
| G-2 | The `→` arrow lines are authored as `- → ...` bullets (not bare `→` plain text). | The renderer parses `[-*+]` as bullets (proper indentation + bullet glyphs). Bare `→` lines parse as plain paragraphs (no bullet formatting). The Slide Content Map shows `→` as distinct arrow lines — bullets with the arrow glyph preserve the visual intent in the PPTX. | 0.88 |
|
||||||
|
| G-3 | The `style:` block in the leadership deck frontmatter replaces `#2E2E2E` (blockquote color in the existing deck) with `#1B1B1B`. | REQ-372.6 allows only 4 hex colors in the source. The existing deck's `style:` uses `#2E2E2E` for blockquote text — this must not appear in the leadership deck source. `#1B1B1B` is the closest S&P token (black). | 1.0 |
|
||||||
|
| G-4 | The render_pptx.py extension parses the Marp frontmatter to extract the `footer:` value (for the footer textbox), but does NOT parse `paginate:`, `theme:`, `size:`, or `style:`. | Minimal extension scope per D-242. Only the footer is needed for REQ-372.5. The other directives are source-only (smoke test checks source; the python-pptx path ignores them). | 0.90 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Escalations
|
||||||
|
|
||||||
|
None. All axes ≥ 0.84 confidence. No human escalation required at
|
||||||
|
full autonomy.
|
||||||
@@ -56,7 +56,8 @@ and covered by the baseline test.
|
|||||||
## OIDC act_runner role (CAP-022, Phase 56)
|
## OIDC act_runner role (CAP-022, Phase 56)
|
||||||
|
|
||||||
The OIDC role for the Gitea `act_runner` was created in Phase 08 and
|
The OIDC role for the Gitea `act_runner` was created in Phase 08 and
|
||||||
gone since (CAPABILITY_INVENTORY.md CAP-022). Phase 56 re-creates it
|
gone since (`archive/CAPABILITY_INVENTORY-v1.10.md` CAP-022, archived
|
||||||
|
v1.27). Phase 56 re-creates it
|
||||||
with a trust policy for the Gitea runner ARN. The role grants the
|
with a trust policy for the Gitea runner ARN. The role grants the
|
||||||
spike-runner-equivalent permissions to the runner via `sts:AssumeRole`,
|
spike-runner-equivalent permissions to the runner via `sts:AssumeRole`,
|
||||||
so the runner does not need a long-lived access key. This closes the
|
so the runner does not need a long-lived access key. This closes the
|
||||||
@@ -73,7 +74,7 @@ bootstrap root key; the runner then assumes the role.
|
|||||||
|
|
||||||
The OIDC role for the Gitea `act_runner` was planned in Phase 08 but
|
The OIDC role for the Gitea `act_runner` was planned in Phase 08 but
|
||||||
never created (the spike used a long-lived key per D-039 waiver).
|
never created (the spike used a long-lived key per D-039 waiver).
|
||||||
CAPABILITY_INVENTORY.md CAP-022 recorded "iam:ListRoles shows no acdl*
|
`archive/CAPABILITY_INVENTORY-v1.10.md` CAP-022 recorded "iam:ListRoles shows no acdl*
|
||||||
roles." Phase 56 re-created the role:
|
roles." Phase 56 re-created the role:
|
||||||
|
|
||||||
- **Role name:** `acdl-act-runner-role`
|
- **Role name:** `acdl-act-runner-role`
|
||||||
|
|||||||
+154
-117
@@ -1,132 +1,152 @@
|
|||||||
# IDEATE — v1.25 kyverno-json Unified Policy Engine
|
# IDEATE — v1.26 Live Pilot Estate Activation
|
||||||
|
|
||||||
> **Autonomy:** full. 3-tier ideation per `config.json ideation.enabled:
|
> **Autonomy:** full. 3-tier ideation per `config.json ideation.enabled:
|
||||||
> true`. `cross_project.enabled: false` → cross-project tier scoped to
|
> true`. `cross_project.enabled: false` → cross-project tier scoped to
|
||||||
> single-project (deferred ideas only, no cross-project candidates
|
> multi-project (deferred ideas only, no cross-project candidates
|
||||||
> accepted). `confidence_threshold: 0.6`, `max_ideas: 20`.
|
> accepted). `confidence_threshold: 0.6`, `max_ideas: 20`.
|
||||||
> Categories: security, quality, architecture, coverage, improvement.
|
> Categories: security, quality, architecture, coverage, improvement.
|
||||||
|
|
||||||
## Tier 1 — Mechanical (pattern-driven, codebase-grounded)
|
## Tier 1 — Mechanical (pattern-driven, codebase-grounded)
|
||||||
|
|
||||||
### I1 — Regression-gate-as-policy ✅ ACCEPTED (REQ-304, REQ-305)
|
### I1 — Outcome-backfill emitter ✅ ACCEPTED (REQ-317)
|
||||||
|
|
||||||
|
**Category:** quality, coverage
|
||||||
|
**Confidence:** 0.92
|
||||||
|
**Pattern:** stuck `pending` status → backfilled from a later event
|
||||||
|
(the most direct metric-grounding pattern).
|
||||||
|
**Source:** `core/metrics/decision_ledger.py:210-211` documents the
|
||||||
|
event chain `confidence.computed → ai.decision.made →
|
||||||
|
attestation.recorded → run.completed/failed`. `collector.py:262`
|
||||||
|
inserts `fact_decision.outcome` as `"pending"` — no backfill step
|
||||||
|
wires `run.completed/failed` back into the decision's outcome. The AI
|
||||||
|
Decision Accuracy metric (`trust_snapshot.py:70-85`) reads
|
||||||
|
`decisions WHERE outcome='succeeded' ÷ total` → 0% today (all pending).
|
||||||
|
**Idea:** `core/metrics/outcome_backfill.py` reads run-manifest
|
||||||
|
`completed`/`failed` events and updates `fact_decision.outcome` +
|
||||||
|
`fact_decision.backfilled_at`. The collector invokes backfill after run
|
||||||
|
completion. Grounds AI Decision Accuracy (Post-Pilot target).
|
||||||
|
**Accepted into:** REQ-317. Phase P3.
|
||||||
|
|
||||||
|
### I2 — `reason='confidence'` escalation tag ✅ ACCEPTED (REQ-318)
|
||||||
|
|
||||||
**Category:** quality, coverage
|
**Category:** quality, coverage
|
||||||
**Confidence:** 0.90
|
**Confidence:** 0.90
|
||||||
**Pattern:** imperative check → declarative policy (the milestone's
|
**Pattern:** boolean field → discriminated field (the metric-numerator
|
||||||
core thesis applied to Nova's own regression gate).
|
precision pattern).
|
||||||
**Source:** `core/regression_verify.py` (CAP-013, CAP-023, CAP-024)
|
**Source:** `core/confidence_signal.py:184` — a `block` band sets
|
||||||
are imperative Python checks. The milestone makes compliance
|
`human_override=True`. The Human Escalation Frequency metric
|
||||||
declarative; Nova's own capability regression should follow.
|
(`docs/metrics/human_escalation_frequency.md:11-12`) is defined as
|
||||||
**Idea:** Port the three capability checks into
|
`count(runs WHERE hitl_block=1 AND reason='confidence') ÷ total runs`.
|
||||||
`adapters/kyverno-json/policies/regression/` as declarative policies
|
The `reason='confidence'` discriminator is not stored today.
|
||||||
over the capability-inventory JSON frontmatter. The imperative
|
**Idea:** `ai.decision.made` gains `escalation_reason: 'confidence'`
|
||||||
`regression_verify.py` stays (it drives the CI gate); the policies are
|
when `band == 'block'`. The collector persists it into `fact_run`.
|
||||||
the declarative mirror that makes capability regression auditable as a
|
Grounds Human Escalation Frequency numerator.
|
||||||
policy artifact.
|
**Accepted into:** REQ-318. Phase P3.
|
||||||
**Accepted into:** REQ-304 (policies), REQ-305 (tests). Phase P4.
|
|
||||||
|
|
||||||
### I2 — Contract-shape validation as policy ✅ ACCEPTED (REQ-295)
|
### I3 — Env-JSON `state_backend` wiring reconciliation ✅ ACCEPTED (REQ-319)
|
||||||
|
|
||||||
**Category:** security, architecture
|
**Category:** architecture, improvement
|
||||||
**Confidence:** 0.92
|
|
||||||
**Pattern:** jsonschema constraint → declarative policy (same constraint,
|
|
||||||
different language, Nova posture on top).
|
|
||||||
**Source:** `schemas/contract.schema.json` required/pattern/enum.
|
|
||||||
**Idea:** The 4 contract policies (`require-id-pattern`,
|
|
||||||
`require-env-in-enum`, `require-infrastructure-min-1`, `forbid-unknown-
|
|
||||||
fields`) are the declarative equivalent of the jsonschema constraints —
|
|
||||||
they let Nova apply its own compliance posture (e.g. forbid a specific
|
|
||||||
env for a specific consumer) on top of schema validity without editing
|
|
||||||
the jsonschema.
|
|
||||||
**Accepted into:** REQ-295. Phase P2.
|
|
||||||
|
|
||||||
### I3 — Stack-IR imperative rules → declarative policies ✅ ACCEPTED (REQ-297)
|
|
||||||
|
|
||||||
**Category:** security, architecture
|
|
||||||
**Confidence:** 0.88
|
**Confidence:** 0.88
|
||||||
**Pattern:** imperative Python rule → declarative kyverno-json policy.
|
**Pattern:** unused config field → wired config field (the
|
||||||
**Source:** `adapters/terraform/policy/custom_rules/nova_tagging.py`
|
single-source-of-truth pattern).
|
||||||
(tagging), the v1.0 demo `public-ingress: true` rule, the v1.8
|
**Source:** `adapters/terraform/adapter.py:116-117` computes the state
|
||||||
D-encryption-default rule.
|
bucket as `nova-tfstate-<AWS_ACCOUNT_ID>-us-east-1` from the
|
||||||
**Idea:** Port the three highest-impact imperative rules into
|
`AWS_ACCOUNT_ID` env var — **not** from the env JSON's
|
||||||
declarative kyverno-json policies over the resolved Stack IR. The
|
`state_backend.bucket`. The env JSON's `state_backend` field is
|
||||||
tagging rule is a cross-check (D-118 — both engines, agree meta-policy);
|
currently unused by the live apply path.
|
||||||
public-ingress and encryption-by-default are kyverno-json only (the IR
|
**Idea:** The adapter reads `env.state_backend.bucket` when present
|
||||||
is the earliest point these can be caught).
|
(falling back to the computed name for backwards compat). `dev.json`
|
||||||
**Accepted into:** REQ-297. Phase P2.
|
gets the real bucket name. Closes the wiring gap so the pilot's env
|
||||||
|
JSON is the single source of truth.
|
||||||
|
**Accepted into:** REQ-319. Phase P3.
|
||||||
|
|
||||||
|
### I4 — Pilot-readiness kyverno-json policy ✅ ACCEPTED (REQ-320)
|
||||||
|
|
||||||
|
**Category:** security, architecture
|
||||||
|
**Confidence:** 0.85
|
||||||
|
**Pattern:** runtime guard → declarative policy (the v1.25 thesis
|
||||||
|
applied to pilot onboarding).
|
||||||
|
**Source:** `core/environment_check.py:48-53` emits a stderr warning
|
||||||
|
(non-fatal) when `account_id == "000000000000"` and env != dev. A
|
||||||
|
warning is not a gate. The pilot should fail-closed if someone tries
|
||||||
|
to apply against a placeholder account.
|
||||||
|
**Idea:** A kyverno-json policy over the env JSON asserting
|
||||||
|
`account_id != "000000000000"` before any apply. Declarative
|
||||||
|
fail-closed gate. Extends v1.25's policy engine to the pilot-onboarding
|
||||||
|
domain.
|
||||||
|
**Accepted into:** REQ-320. Phase P3.
|
||||||
|
|
||||||
## Tier 2 — Backend-enriched (signal-driven)
|
## Tier 2 — Backend-enriched (signal-driven)
|
||||||
|
|
||||||
### I4 — Plan-JSON Checkov RULE_MAP → kyverno-json mirrors ✅ ACCEPTED (REQ-300)
|
### I5 — Settlement-finality kyverno-json policy ✅ ACCEPTED (REQ-315)
|
||||||
|
|
||||||
**Category:** security, coverage
|
**Category:** security, coverage
|
||||||
**Confidence:** 0.85
|
**Confidence:** 0.82
|
||||||
**Pattern:** existing engine rule → declarative mirror in the new engine
|
**Pattern:** domain invariant → declarative policy (the v1.25 thesis
|
||||||
(defense-in-depth against engine drift).
|
applied to the securities domain — the most novel use of kyverno-json
|
||||||
**Source:** `checkov_adapter.py:RULE_MAP` (CKV_AWS_41/45/46, CKV_AWS_1/40,
|
in v1.26).
|
||||||
CKV_AWS_7/33).
|
**Source:** The pilot's settlement service records matches as
|
||||||
**Idea:** Port the 6 Checkov rules over `terraform_plan` into declarative
|
transactions on the chain; settlement finality = block commit. The
|
||||||
kyverno-json policies over `terraform show -json` output. The Checkov
|
NORTH_STAR Objective #2 (provable trust) says trust should be a policy
|
||||||
rules stay the source of truth for HCL scanning; the kyverno-json
|
artifact, not a promise. Today settlement finality is a runtime
|
||||||
policies are mirrors (different rule language, same plan JSON). Defense-
|
property of the chain; making it a declarative policy turns it into an
|
||||||
in-depth: if Checkov and kyverno-json disagree on the same plan, the
|
auditable gate.
|
||||||
divergence is visible (two PCRs with different results for the same
|
**Idea:** A kyverno-json policy over the settlement-service status JSON
|
||||||
resource).
|
asserting `all_committed: true` before any promotion (qa→prod). The
|
||||||
**Accepted into:** REQ-300. Phase P3.
|
securities-specific extension of v1.25's policy engine. The policy is
|
||||||
|
skip-when-kj-absent (graceful).
|
||||||
|
**Accepted into:** REQ-315. Phase P3.
|
||||||
|
|
||||||
### I5 — Meta-policy over the merged PCR list ✅ ACCEPTED (REQ-303)
|
### I6 — Pilot-estate regression capability (CAP-025) ✅ ACCEPTED (REQ-316)
|
||||||
|
|
||||||
**Category:** architecture, quality
|
**Category:** quality, coverage
|
||||||
**Confidence:** 0.90
|
**Confidence:** 0.88
|
||||||
**Pattern:** the policy result list is itself a policy target (the most
|
**Pattern:** manual e2e → regression-gated capability (the v1.0 CAP
|
||||||
novel use of kyverno-json in v1.25).
|
pattern applied to the pilot).
|
||||||
**Source:** `core/confidence_signal.py` PENALTY hardcode (critical
|
**Source:** `core/regression_verify.py` has CAP-013..024 (live-AWS +
|
||||||
override), the D-118 tagging cross-check.
|
local tiers). The pilot estate is a new live-AWS capability —
|
||||||
**Idea:** `block-on-any-critical` (declarative "critical = block") +
|
"contract resolve → adapter compile → terraform plan → policy scan →
|
||||||
`tagging-rules-agree` (Checkov vs kj agree). The meta-policies consume
|
confidence signal → attestation → outbox record" against
|
||||||
the merged PCR list as their payload. The critical-block meta-policy is
|
`581513795199`. Without a regression CAP, the pilot could silently
|
||||||
the declarative source of truth; the `confidence_signal.py` hard-override
|
decay.
|
||||||
stays as defense-in-depth (D-119).
|
**Idea:** CAP-025 (live-pilot-apply) in the regression gate. The
|
||||||
**Accepted into:** REQ-303. Phase P3.
|
round-trip assertion. Grounds the pilot as a maintained capability,
|
||||||
|
not a one-shot demo.
|
||||||
|
**Accepted into:** REQ-316. Phase P3.
|
||||||
|
|
||||||
### I6 — Env-transition destroy as a declarative policy ❌ DEFERRED
|
### I7 — DynamoDB L1 primitive ✅ ACCEPTED (REQ-322)
|
||||||
|
|
||||||
|
**Category:** architecture, coverage
|
||||||
|
**Confidence:** 0.95
|
||||||
|
**Pattern:** missing primitive → authored module (the v1.7 + v1.8
|
||||||
|
module-build-out pattern).
|
||||||
|
**Source:** RESEARCH §3.4 — no `modules/l1/dynamodb/` exists. The
|
||||||
|
blockchain exchange's ledger table needs it. The adapter is
|
||||||
|
stateless/registry-driven (no `TYPE_MAP`); a new stack type requires a
|
||||||
|
new L1 module, not an adapter change.
|
||||||
|
**Idea:** Author `modules/l1/dynamodb/` (interface.json +
|
||||||
|
terraform/main.tf + README.md + instance.json + registry.json entry).
|
||||||
|
The single platform-side module build-out for the milestone. Follows
|
||||||
|
the `s3`/`rds` primitive template. Encryption + PITR enabled per v1.8
|
||||||
|
NFR defaults.
|
||||||
|
**Accepted into:** REQ-322. Phase P3.
|
||||||
|
|
||||||
|
### I8 — Stale `adapters/README.md` TYPE_MAP references ❌ DEFERRED (scope)
|
||||||
|
|
||||||
**Category:** improvement
|
**Category:** improvement
|
||||||
**Confidence:** 0.55 (below threshold — deferred, not rejected)
|
**Confidence:** 0.70 (above threshold, but scoped into REQ-321)
|
||||||
**Pattern:** imperative lifecycle Python → declarative policy.
|
**Pattern:** stale doc → corrected doc.
|
||||||
**Source:** `core/env_transition.py` (v1.24 detect-and-destroy).
|
**Source:** `adapters/README.md:49-54` references the deleted
|
||||||
**Idea:** The v1.24 env-transition destroy logic (detect env change via
|
`TYPE_MAP`/`INPUT_MAP`/`OUTPUT_MAP` — contradicts `adapter.py:1-11` +
|
||||||
DynamoDB, destroy prior env, fail-closed) is imperative Python. A
|
`modules/STANDARDS.md:212-214`.
|
||||||
declarative kyverno-json policy could assert "if `environment` changed
|
**Idea:** Fix the stale references as part of the docs phase.
|
||||||
on a stable `contract.id`, a destroy event MUST precede the apply" —
|
**Reason deferred as a standalone idea:** Already captured in REQ-321
|
||||||
turning the lifecycle enforcement into an auditable policy artifact.
|
(docs + adapter README). No new requirement needed — the fix lands in
|
||||||
**Reason deferred:** The env-transition logic is *stateful* (DynamoDB
|
P4 docs.
|
||||||
queries, terraform state inspection) — kyverno-json policies are
|
|
||||||
*stateless* (payload in, PCRs out). A policy can assert the *contract*
|
|
||||||
shape (the env value is valid) but not the *lifecycle* (the prior env
|
|
||||||
was destroyed). The stateful check stays in `core/env_transition.py`;
|
|
||||||
a future milestone could emit a `nova.env.destroyed` event that a
|
|
||||||
kyverno-json policy then asserts is present in the evidence stream
|
|
||||||
(event-as-policy). Recorded as a future-idea, not a v1.25 requirement.
|
|
||||||
|
|
||||||
### I7 — Drift detection as policy ❌ DEFERRED
|
## Tier 3 — Cross-project (deferred — multi-project, but cross-project sharing disabled)
|
||||||
|
|
||||||
**Category:** security, coverage
|
### I9 — Cross-project policy sharing ❌ DEFERRED (config)
|
||||||
**Confidence:** 0.40 (below threshold — deferred)
|
|
||||||
**Pattern:** scheduled job → policy over the drift report.
|
|
||||||
**Source:** NORTH_STAR.md Non-Goal #4 (drift detection scheduled job,
|
|
||||||
deferred — D-096 + no scheduler).
|
|
||||||
**Idea:** A kyverno-json policy over a terraform drift report could
|
|
||||||
assert "no drifted resources" declaratively. But drift detection itself
|
|
||||||
requires a scheduled `terraform plan -detailed-exitcode` job, which is
|
|
||||||
deferred (no scheduler). The policy is the easy part; the emitter is the
|
|
||||||
blocking dependency.
|
|
||||||
**Reason deferred:** Blocked by D-096 + no scheduler (same as NORTH_STAR
|
|
||||||
Non-Goal #4). The policy shape is documented for when the emitter ships.
|
|
||||||
|
|
||||||
## Tier 3 — Cross-project (deferred — single project)
|
|
||||||
|
|
||||||
### I8 — Cross-project policy sharing ❌ DEFERRED (config)
|
|
||||||
|
|
||||||
**Category:** improvement
|
**Category:** improvement
|
||||||
**Confidence:** N/A
|
**Confidence:** N/A
|
||||||
@@ -134,24 +154,41 @@ Non-Goal #4). The policy shape is documented for when the emitter ships.
|
|||||||
**Source:** `config.json ideation.cross_project.enabled: false`.
|
**Source:** `config.json ideation.cross_project.enabled: false`.
|
||||||
**Idea:** In a multi-project org, kyverno-json policies could be shared
|
**Idea:** In a multi-project org, kyverno-json policies could be shared
|
||||||
across projects (a tagging standard policy applies to all projects).
|
across projects (a tagging standard policy applies to all projects).
|
||||||
**Reason deferred:** ACDL is single-project (`active_projects: ["acdl"]`).
|
**Reason deferred:** `cross_project.enabled: false`. Even though
|
||||||
Cross-project ideation is disabled in config. Recorded for when the
|
v1.26 is multi-project (acdl + nova-blockchain-exchange),
|
||||||
org grows.
|
cross-project *ideation* is disabled in config. Recorded for when the
|
||||||
|
org grows + the flag is enabled.
|
||||||
|
|
||||||
|
### I10 — Consumer-repo CI scaffolding as a reusable template ❌ DEFERRED
|
||||||
|
|
||||||
|
**Category:** improvement
|
||||||
|
**Confidence:** 0.55 (below threshold — deferred, not rejected)
|
||||||
|
**Pattern:** one-off CI → reusable template.
|
||||||
|
**Source:** The consumer repo (`nova-blockchain-exchange`) needs its
|
||||||
|
own CI (`ci.yml` — lint + pytest). If Nova expects many consumers, a
|
||||||
|
reusable consumer-CI template would reduce onboarding friction.
|
||||||
|
**Idea:** A `nova-consumer-template` repo (or a
|
||||||
|
`.github/workflow-templates/` dir) that new consumers instantiate.
|
||||||
|
**Reason deferred:** Nova has 1 consumer today (the pilot). A template
|
||||||
|
is premature abstraction until the 2nd consumer arrives. The pilot's
|
||||||
|
CI is authored directly (REQ-310..312 tests). Recorded for when the
|
||||||
|
3rd consumer onboards.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- 5 ideas accepted (I1..I5) → already captured as REQ-295, REQ-297,
|
- 7 ideas accepted (I1..I7) → already captured as REQ-315, REQ-316,
|
||||||
REQ-300, REQ-303, REQ-304, REQ-305.
|
REQ-317, REQ-318, REQ-319, REQ-320, REQ-322.
|
||||||
- 3 ideas deferred (I6, I7, I8) with documented blocking reasons.
|
- 3 ideas deferred (I8 scoped into REQ-321; I9 config-disabled; I10
|
||||||
|
below threshold) with documented blocking reasons.
|
||||||
- 0 ideas rejected (below-threshold ideas are deferred, not rejected —
|
- 0 ideas rejected (below-threshold ideas are deferred, not rejected —
|
||||||
they may activate when their blockers lift).
|
they may activate when their blockers lift).
|
||||||
- The accepted ideas are the **quality improvement** the user asked for
|
- The accepted ideas are the **quality improvement** the `--ideate` flag
|
||||||
("ideate and explore how it can be used within the Nova platform to
|
drives: I1 + I2 ground the Post-Pilot metrics (outcome backfill +
|
||||||
improve quality of the platform checks"): I1 (regression-gate-as-
|
escalation reason); I3 closes the env-JSON wiring gap; I4 + I5 extend
|
||||||
policy) is the headline quality improvement; I4 + I5 are the defense-
|
v1.25's policy engine to the pilot domain (pilot-readiness +
|
||||||
in-depth coverage improvements; I2 + I3 are the architecture
|
settlement-finality); I6 gates the pilot as a maintained capability;
|
||||||
improvements (imperative → declarative).
|
I7 is the single platform-side module build-out.
|
||||||
- No new requirements added beyond REQ-291..309 (the accepted ideas are
|
- No new requirements added beyond REQ-310..322 (the accepted ideas are
|
||||||
already scoped into the existing requirements). The IDEATE pass
|
already scoped into the existing requirements). The IDEATE pass
|
||||||
validated the requirement set rather than expanding it — the ideas
|
validated the requirement set rather than expanding it — the ideas
|
||||||
were anticipated in the SPECIFY stage and explicitly captured.
|
were anticipated in the SPECIFY + RESEARCH stages.
|
||||||
@@ -231,6 +231,18 @@ their AI engineering teams reach for first when an agent needs to deploy.
|
|||||||
leadership. The deck's Proof section cites grounded metrics; its
|
leadership. The deck's Proof section cites grounded metrics; its
|
||||||
Roadmap section cites deferred targets honestly.
|
Roadmap section cites deferred targets honestly.
|
||||||
|
|
||||||
|
## Relationship to engineering files (v1.27 update)
|
||||||
|
|
||||||
|
- **NORTH_STAR.md** (this file) = the *why* — PO-authored strategic
|
||||||
|
direction, loaded every ci-run via `config.strategic_direction_file`.
|
||||||
|
- **STATE.md** = the *what exists* — PO-owned capability catalog,
|
||||||
|
additive, updated at every milestone ship (P-final Wave 3). The PO
|
||||||
|
reads STATE.md before writing new REQ-NNN specs to avoid re-spec'ing
|
||||||
|
existing capability and to respect the invariants.
|
||||||
|
- **ARCHITECTURE.md** = the *how* — the durable target architecture.
|
||||||
|
- **CHECKPOINT.json** = the *now* — authoritative live phase/ship
|
||||||
|
state.
|
||||||
|
|
||||||
## v1.25 update — swappable policy-engine substrate
|
## v1.25 update — swappable policy-engine substrate
|
||||||
|
|
||||||
Strategic Objective #2 (provable trust) gained a concrete substrate in
|
Strategic Objective #2 (provable trust) gained a concrete substrate in
|
||||||
|
|||||||
+94
-115
@@ -1,132 +1,111 @@
|
|||||||
---
|
---
|
||||||
project: acdl
|
project: acdl
|
||||||
milestone: v1.25
|
milestone: v1.30
|
||||||
generated_at: 2026-08-12
|
generated_at: 2026-08-20
|
||||||
generator: lead-developer
|
generator: lead-developer
|
||||||
verification_toolchain:
|
verification_toolchain:
|
||||||
typecheck: "python3 -m py_compile core/policy_engine.py adapters/kyverno-json/kyverno_json_engine.py tests/test_policy_engine.py tests/test_kyverno_json_engine.py"
|
typecheck: "python3 -m py_compile scripts/render_pptx.py 2>&1 | head -5 || true"
|
||||||
test: "pytest tests/test_policy_engine.py tests/test_kyverno_json_engine.py tests/test_adapter.py tests/test_contract_resolver.py tests/test_confidence_signal.py tests/test_checkov_adapter.py tests/test_kyverno_adapter.py tests/test_pipeline.py -v"
|
test: "bash scripts/check_leadership_deck.sh 2>&1 | tail -20; echo \"exit=$?\""
|
||||||
lint: "ruff check core/policy_engine.py adapters/kyverno-json/ 2>/dev/null || python3 -m py_compile core/policy_engine.py"
|
lint: "python3 -c \"import pptx; print('python-pptx', pptx.__version__)\" 2>&1"
|
||||||
note: |
|
note: |
|
||||||
v1.25 is the kyverno-json Unified Policy Engine milestone — a feat
|
v1.30 is a single-shot presentation artifact milestone (Leadership
|
||||||
milestone. Four active personas: lead-developer (coordination +
|
Deck). Four active personas: lead-developer (coordination + STATE.md
|
||||||
docs + ARCHITECTURE.md §12.7), backend-engineer (core/policy_engine.py
|
CAP-042 + PROJECT.md D-241), backend-engineer (render_pptx.py
|
||||||
protocol + registry + contract_resolver.py wiring + run_platform.sh
|
extension + PPTX render + python-pptx install), ci-doc-writer
|
||||||
Step 5 + pipeline tests), policy-engineer (adapters/kyverno-json/
|
(custom, phase-specific — Marp markdown deck authoring), ci-cli-
|
||||||
engine + policies across all 4 target dirs + meta-policies + policy
|
engineer (custom — smoke-test script). frontend-engineer +
|
||||||
tests + adapter README + STANDARDS.md policy-authoring section),
|
data-engineer + security-engineer deactivated (no UI, no data
|
||||||
data-engineer (config.json policy object + schemas/README.md note +
|
pipelines, no runtime security surface — the deck is a static
|
||||||
capability-inventory JSON fixture for regression policies).
|
artifact). The render_pptx.py extension (D-242) is the only code
|
||||||
frontend-engineer stays deactivated (no UI). The policy-engineer is a
|
change; it is a narrow prerequisite, not a REQ-372 deliverable.
|
||||||
new custom persona created for this milestone's policy domain (see
|
|
||||||
RESEARCH.md §4 — kyverno-json + JMESPath is a distinct framework from
|
|
||||||
backend-engineer's fastify/hono).
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACDL — Persona Roster (v1.25 kyverno-json Unified Policy Engine)
|
# Personas — v1.30 Single-shot Leadership Deck
|
||||||
|
|
||||||
> v1.25 roster. Four active personas + one deactivated. This is a feat
|
## Roster
|
||||||
> milestone: the work is a swappable policy-engine protocol + a new
|
|
||||||
> adapter + policies across 4 Nova artifacts + pipeline wiring + docs.
|
|
||||||
> The policy-engineer is a new custom persona — kyverno-json + JMESPath
|
|
||||||
> is a specialized domain that doesn't fit backend-engineer's
|
|
||||||
> fastify/hono frameworks or data-engineer's drizzle/postgresql.
|
|
||||||
|
|
||||||
## Active personas
|
|
||||||
|
|
||||||
### lead-developer
|
### lead-developer
|
||||||
- **Domain:** coordination + docs
|
```yaml
|
||||||
- **Frameworks:** []
|
active: true
|
||||||
- **Constraints:** ["pragmatic", "battle-tested defaults", "docs match code", "swap boundary is the moat"]
|
domain: "Milestone coordination, STATE.md CAP-042, PROJECT.md D-241 record, ship discipline"
|
||||||
- **Territory:**
|
frameworks: []
|
||||||
- `.ciagent/ARCHITECTURE.md` (§12.7 Policy Engine Registry — NEW)
|
constraints: ["pragmatic", "battle-tested defaults", "D-241", "D-242", "D-243"]
|
||||||
- `.ciagent/PROJECT.md` (v1.25 section)
|
territory:
|
||||||
- `.ciagent/REQUIREMENTS.md` (v1.25 section)
|
- ".ciagent/STATE.md"
|
||||||
- `.ciagent/ROADMAP.md` (v1.25 section)
|
- ".ciagent/PROJECT.md"
|
||||||
- `.ciagent/PLAN.md`, `.ciagent/RESEARCH.md`, `.ciagent/CLARIFY.md`,
|
- ".ciagent/CHECKPOINT.json"
|
||||||
`.ciagent/GRILL.md`, `.ciagent/PERSONAS.md`
|
- ".ciagent/REQUIREMENTS.md"
|
||||||
- `docs/METRICS.md` (swappable engine narrative — REQ-307)
|
- ".ciagent/ROADMAP.md"
|
||||||
- **Reason:** Owns the milestone coordination + the architecture
|
reason: "Owns the ship-wave records (CAP-042, D-241) and milestone coordination. The deck is a single-shot artifact; the lead-developer ensures the STATE.md/PROJECT.md records are appended correctly at ship."
|
||||||
narrative. The swap boundary (PolicyEngine protocol) is the moat per
|
```
|
||||||
Strategic Objective #2 — the lead-developer owns the boundary
|
|
||||||
description in ARCHITECTURE.md §12.7 and the docs/METRICS.md note.
|
|
||||||
No Python policy code (backend-engineer + policy-engineer territory).
|
|
||||||
No UI (frontend-engineer deactivated).
|
|
||||||
|
|
||||||
### backend-engineer
|
### backend-engineer
|
||||||
- **Domain:** backend (Python + bash + pipeline wiring)
|
```yaml
|
||||||
- **Frameworks:** ["boto3", "terraform"]
|
active: true
|
||||||
- **Constraints:** ["api-first", "strict-typing", "engine-agnostic confidence signal", "fail-soft when kj absent"]
|
domain: "scripts/render_pptx.py extension (path arg + custom output + footer textbox), PPTX render, python-pptx install"
|
||||||
- **Territory:**
|
frameworks: ["Python 3.11", "python-pptx 1.0.2", "pip"]
|
||||||
- `core/policy_engine.py` (NEW — PolicyEngine Protocol + PolicyEngineRegistry + NullEngine)
|
constraints: ["D-242", "narrow extension only", "no new renderer", "S&P theme tokens only in source"]
|
||||||
- `core/contract_resolver.py` (MODIFIED — invoke registry pre/post resolve)
|
territory:
|
||||||
- `scripts/run_platform.sh` (MODIFIED — Step 5 kyverno-json parallel pass)
|
- "scripts/render_pptx.py"
|
||||||
- `scripts/install-kyverno-json.sh` (NEW)
|
- "docs/presentations/nova-leadership-deck.pptx"
|
||||||
- `tests/test_policy_engine.py` (NEW — protocol conformance, registry, NullEngine)
|
reason: "Owns the narrow render_pptx.py extension (D-242) and the PPTX render. Frameworks overridden from fastify/hono (default) to python-pptx (actual project dependency for this milestone). The extension is a non-REQ-372 prerequisite per spec §3.3 Edge 2."
|
||||||
- `tests/test_run_platform_plan_json_policies.py` (NEW — script-substring assertion)
|
```
|
||||||
- `.github/workflows/ci.yml` + `.gitea/workflows/ci.yml` (MODIFIED — Go + kj install)
|
|
||||||
- **Reason:** Owns the Python protocol layer + the pipeline wiring. The
|
|
||||||
`PolicyEngine` Protocol + `PolicyEngineRegistry` are Python structural-
|
|
||||||
typing constructs (PEP 544) — backend-engineer's strict-typing
|
|
||||||
constraint. The `contract_resolver.py` wiring + `run_platform.sh`
|
|
||||||
Step 5 are backend territory. Does NOT write kyverno-json policy
|
|
||||||
files (policy-engineer territory) — only the Python that *invokes* the
|
|
||||||
engine. Does NOT modify the confidence signal (it already consumes
|
|
||||||
`list[PolicyCheckResult]` engine-agnostically — PROJECT.md hard-
|
|
||||||
constraint).
|
|
||||||
|
|
||||||
### policy-engineer
|
### ci-doc-writer
|
||||||
- **Domain:** policy (declarative compliance rules)
|
```yaml
|
||||||
- **Frameworks:** ["kyverno-json", "jmespath", "kyverno ValidatingPolicy"]
|
active: true
|
||||||
- **Constraints:** ["declarative-policies", "no-imperative-rules", "schema-validated", "severity-via-annotation", "assertion-trees-not-foreach"]
|
phase_specific: true
|
||||||
- **Territory:**
|
domain: "Marp markdown deck authoring (7 slides, speaker notes, [1] citations, S&P theme)"
|
||||||
- `adapters/kyverno-json/` (NEW — engine impl + __init__.py + README)
|
frameworks: ["Marp", "Markdown"]
|
||||||
- `adapters/kyverno-json/kyverno_json_engine.py` (NEW — KyvernoJsonEngine)
|
constraints: ["REQ-372.1", "REQ-372.3", "REQ-372.4", "REQ-372.6", "REQ-372.7", "REQ-372.9", "REQ-372.12", "D-241", "D-243"]
|
||||||
- `adapters/kyverno-json/policies/` (NEW — all 4 target dirs: contract/, stack-ir/, plan-json/, meta/, regression/)
|
territory:
|
||||||
- `adapters/kyverno-json/policies/_smoke.json` (NEW)
|
- "docs/presentations/nova-leadership-deck-marp.md"
|
||||||
- `adapters/README.md` (MODIFIED — new adapter row + PolicyEngine Protocol section)
|
reason: "Custom persona for presentation authoring. Created for P1 (the deck is the primary deliverable). Removed after P1 ships. The deck is hand-authored against the Slide Content Map in PROJECT.md §v1.30 — NOT a compression (D-241)."
|
||||||
- `tests/test_kyverno_json_engine.py` (NEW — PCR schema validity, defensive parsing)
|
```
|
||||||
- `tests/test_stack_ir_policies.py` (NEW)
|
|
||||||
- `tests/test_plan_json_policies.py` (NEW)
|
|
||||||
- `tests/test_meta_policies.py` (NEW)
|
|
||||||
- `tests/test_regression_policies.py` (NEW)
|
|
||||||
- `tests/fixtures/stack_ir/`, `tests/fixtures/plan_json/`, `tests/fixtures/capability_inventory.json` (NEW)
|
|
||||||
- `modules/STANDARDS.md` (MODIFIED — Policy authoring standard section — REQ-307)
|
|
||||||
- **Reason:** The policy-engineer owns the declarative policy artifacts.
|
|
||||||
kyverno-json's `ValidatingPolicy` + assertion trees + JMESPath is a
|
|
||||||
distinct framework from backend-engineer's fastify/hono and requires
|
|
||||||
its own constraints: no imperative rules (everything is an assertion
|
|
||||||
tree), severity via the `nova.cloudinit.dev/severity` annotation (not
|
|
||||||
in the engine adapter), no `forEach` (use the `~` modifier). The
|
|
||||||
adapter pattern (engine ↔ protocol ↔ registry) is backend-engineer
|
|
||||||
territory, but the policy *content* and the engine *translation*
|
|
||||||
(`_to_pcr()`) are policy-engineer territory because they require
|
|
||||||
kyverno-json output-shape knowledge. Created per RESEARCH.md §4 — this
|
|
||||||
is a phase-spanning persona (active for P1..P4), not phase-specific.
|
|
||||||
|
|
||||||
### data-engineer
|
### ci-cli-engineer
|
||||||
- **Domain:** data (config schema + structured fixtures)
|
```yaml
|
||||||
- **Frameworks:** ["jsonschema", "yaml"]
|
active: true
|
||||||
- **Constraints:** ["schema-first", "type-safe config", "backward-compatible additions"]
|
domain: "Smoke-test script (bash, runnable on demand, NOT a CI gate)"
|
||||||
- **Territory:**
|
frameworks: ["Bash", "grep", "awk", "wc"]
|
||||||
- `.ciagent/config.json` (MODIFIED — new `policy` object: engine + policy_root)
|
constraints: ["REQ-372.8", "not a CI gate", "exit 0 on pass", "non-zero on fail"]
|
||||||
- `schemas/policy_check_result.schema.json` (READ-ONLY — no change per D-116)
|
territory:
|
||||||
- `schemas/README.md` (MODIFIED — note engine: "kyverno" shared by K8s adapter + kj)
|
- "scripts/check_leadership_deck.sh"
|
||||||
- `tests/fixtures/capability_inventory.json` (NEW — clean + drifted inventory fixtures for regression policies)
|
reason: "Custom persona for the smoke-test script. Owns the 6 assertions (a–f): file exists, slide count=7, word bands, footer string, S&P colors only, PPTX exists. Pure bash — no python dependency (keeps it runnable without the python-pptx install)."
|
||||||
- **Reason:** The `config.json.policy` object is a schema-first addition
|
```
|
||||||
(new top-level key with `engine` + `policy_root` fields). The
|
|
||||||
capability-inventory JSON fixtures for the regression-gate policies
|
|
||||||
(REQ-304) are structured data — the data-engineer owns the fixture
|
|
||||||
shape. The `policy_check_result.schema.json` is read-only (D-116 — no
|
|
||||||
enum change); the data-engineer documents the `engine: "kyverno"`
|
|
||||||
sharing in `schemas/README.md`. No migrations (no database). No Python
|
|
||||||
(backend-engineer + policy-engineer territory).
|
|
||||||
|
|
||||||
## Deactivated personas
|
## Deactivated
|
||||||
|
|
||||||
### frontend-engineer
|
### frontend-engineer
|
||||||
- **active:** false
|
```yaml
|
||||||
- **Reason:** ACDL has no frontend (no package.json — confirmed in
|
active: false
|
||||||
config.json personas.personas[frontend-engineer].reason). v1.25 adds
|
reason: "ACDL has no frontend (no package.json); the deck is markdown (ci-doc-writer territory). Already deactivated in config.json personas[3]."
|
||||||
no UI work — the policy engine is backend + policy artifacts only.
|
```
|
||||||
Deactivated per the v1.15+ convention.
|
|
||||||
|
### data-engineer
|
||||||
|
```yaml
|
||||||
|
active: false
|
||||||
|
reason: "No schema/migration/data-pipeline work in v1.30. The milestone is a single-shot presentation artifact."
|
||||||
|
```
|
||||||
|
|
||||||
|
### security-engineer
|
||||||
|
```yaml
|
||||||
|
active: false
|
||||||
|
reason: "No runtime security surface in v1.30. The deck is a static artifact; the existing security posture (ABAC, KMS, JWKS) is referenced in slide content, not modified. Security review of the deck content is handled by the verify stage (no secrets, no publish.yml integration)."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase-specific persona lifecycle
|
||||||
|
|
||||||
|
- **ci-doc-writer**: created for P1, removed after P1 ships. The deck
|
||||||
|
source is the deliverable; no further presentation authoring in P2
|
||||||
|
(final review only).
|
||||||
|
- All other personas persist through P2 (final review + ship).
|
||||||
|
|
||||||
|
## Territory enforcement
|
||||||
|
|
||||||
|
- **Mode:** `warn` (per `config.json personas.territory_enforcement`).
|
||||||
|
- **Note:** v1.30 has a small, non-overlapping territory surface.
|
||||||
|
`lead-developer` owns `.ciagent/STATE.md` + `.ciagent/PROJECT.md`;
|
||||||
|
`backend-engineer` owns `scripts/render_pptx.py` + the PPTX;
|
||||||
|
`ci-doc-writer` owns the markdown source; `ci-cli-engineer` owns
|
||||||
|
the smoke-test script. No territory conflicts expected.
|
||||||
+231
-327
@@ -1,371 +1,275 @@
|
|||||||
# PLAN — v1.25 (kyverno-json Unified Policy Engine)
|
# PLAN — v1.30 Single-shot Leadership Deck
|
||||||
|
|
||||||
> Feature milestone. Tags on the **v1.24.x** line: v1.24.0 (P0) →
|
> **Milestone:** v1.30 (feature — single-shot PPTX leadership deck).
|
||||||
> v1.24.1 (P1) → v1.24.2 (P2) → v1.24.3 (P3) → v1.24.4 (P4) → v1.24.5
|
> Tags on the **v1.29.x** line: `v1.29.1` (P0) → `v1.29.2` (P1
|
||||||
> (P5 final = milestone release). 19 requirements (REQ-291..309),
|
> execution) → `v1.29.3` (P2 final = milestone release). The final
|
||||||
> 4 execution phases + P0 pre-execution + P5 final review/ship.
|
> phase's patch IS the milestone release.
|
||||||
|
> **Branch:** `milestone/v1.30-leadership-deck`. Phase branches:
|
||||||
|
> `phase/00-pre-execution`, `phase/01-leadership-deck`,
|
||||||
|
> `phase/02-final-review-ship`.
|
||||||
|
>
|
||||||
|
> **Tags:** `v1.29.1` (P0) → `v1.29.2` (P1) → `v1.29.3` (P2 final =
|
||||||
|
> milestone release). **1 execution phase** (P1) — this is a
|
||||||
|
> single-shot artifact, not a multi-phase build.
|
||||||
|
|
||||||
## Wave model
|
## Milestone goal
|
||||||
|
|
||||||
Each phase is a **vertical slice** (end-to-end: policy files + Python
|
A single-shot, 7-slide PPTX leadership deck for Infrastructure &
|
||||||
wiring + tests + docs). Phases are ordered by dependency: the engine
|
Operations leadership (CTO + VP Technology + Product Management),
|
||||||
protocol (P1) must exist before policies (P2/P3) can be wired; the
|
presented live in August 2026, securing architecture endorsement and
|
||||||
pipeline wiring (P3) must exist before the meta-policies (P3) can
|
a November 2026 runway. Authored as Marp markdown, rendered via the
|
||||||
consume the merged PCR list; the regression-gate policies (P4) are
|
existing `scripts/render_pptx.py` (narrowly extended per D-242),
|
||||||
independent of the pipeline and can be authored in parallel with P3's
|
verified by `scripts/check_leadership_deck.sh`. The deck is discrete
|
||||||
tests, but ship after P3 because they reference the engine registry
|
from the existing citizen-developer pitch (D-241: NOT a compression).
|
||||||
finalized in P1. Within each phase, the waves are the persona task
|
|
||||||
groups (parallelizable across personas when `parallelization.enabled:
|
## Requirements
|
||||||
true`, `max_concurrent_agents: 5`).
|
|
||||||
|
12 requirements: REQ-372.1..REQ-372.12 (full text in
|
||||||
|
`.ciagent/REQUIREMENTS.md` §v1.30). 1 capability: CAP-042. 3
|
||||||
|
decisions: D-241..D-243 (CLARIFY). Vision grounding: `[1]` →
|
||||||
|
`docs/vision.md`.
|
||||||
|
|
||||||
## Phase breakdown
|
## Phase breakdown
|
||||||
|
|
||||||
### Phase P1 — engine-core (Wave 1, backend-engineer + policy-engineer + data-engineer)
|
### Phase P1 — leadership-deck (REQ-372.1..REQ-372.12)
|
||||||
|
|
||||||
**Type:** `feat` (engine protocol + registry + kyverno-json engine adapter + install + tests)
|
**Goal:** Author the Marp markdown deck source, extend the render
|
||||||
|
pipeline, render the PPTX, author the smoke test, and append the
|
||||||
|
ship-wave records (CAP-042, D-241). The deck is a single-shot
|
||||||
|
artifact; all 12 REQs ship in this one phase.
|
||||||
|
|
||||||
**Requirements:** REQ-291, REQ-292, REQ-293, REQ-294, REQ-308, REQ-309
|
**Personas:** lead-developer, backend-engineer, ci-doc-writer
|
||||||
|
(phase-specific), ci-cli-engineer.
|
||||||
|
|
||||||
|
**Territory:** `docs/presentations/nova-leadership-deck-marp.md`
|
||||||
|
(ci-doc-writer), `scripts/render_pptx.py` +
|
||||||
|
`docs/presentations/nova-leadership-deck.pptx` (backend-engineer),
|
||||||
|
`scripts/check_leadership_deck.sh` (ci-cli-engineer),
|
||||||
|
`.ciagent/STATE.md` + `.ciagent/PROJECT.md` (lead-developer).
|
||||||
|
|
||||||
|
#### Wave 1 — render pipeline prerequisite (backend-engineer)
|
||||||
|
|
||||||
|
**Task P1.W1.T1:** Extend `scripts/render_pptx.py` per D-242:
|
||||||
|
- Accept an explicit source `.md` path as argv[1] (if it ends in
|
||||||
|
`.md` and contains a `/`, treat as a path; else treat as a deck
|
||||||
|
name per the existing convention — backward compatible).
|
||||||
|
- Accept `--output <path>` for the custom output filename. Default:
|
||||||
|
derive from the source name (strip `-marp.md` → add `.pptx`) for
|
||||||
|
backward compatibility.
|
||||||
|
- Add a `_add_footer(slide, text)` helper that adds a right-aligned
|
||||||
|
textbox at the bottom of every slide with the exact string
|
||||||
|
`Nova Platform - Infrastructure & Operations` (grey, small). Call
|
||||||
|
it in both `render_title_slide` and `render_content_slide`.
|
||||||
|
- The footer text is read from the Marp frontmatter `footer:`
|
||||||
|
directive if present; else default to the existing deck's footer
|
||||||
|
(backward compatible). Parse the frontmatter to extract the
|
||||||
|
`footer:` value (the existing code strips frontmatter without
|
||||||
|
reading it — add a frontmatter parser).
|
||||||
|
- **No other renderer change.** Speaker notes remain skipped
|
||||||
|
(acceptable per RESEARCH R1).
|
||||||
|
|
||||||
**Must-haves:**
|
**Must-haves:**
|
||||||
- `core/policy_engine.py` — `PolicyEngine` Protocol (PEP 544) +
|
- `python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md --output docs/presentations/nova-leadership-deck.pptx` works.
|
||||||
`PolicyEngineRegistry` (selects from `config.json.policy.engine`) +
|
- `python3 scripts/render_pptx.py nova-autonomous-cloud-delivery` still works (backward compatible — renders `{deck}-marp.md` → `{deck}-python.pptx`).
|
||||||
`NullEngine` fallback (emits `SKIPPED` when `policy` key absent)
|
- Every rendered slide has a right-aligned footer textbox.
|
||||||
(REQ-291)
|
- `python3 -m py_compile scripts/render_pptx.py` exits 0.
|
||||||
- `.ciagent/config.json` gains `policy` object: `{"engine":
|
|
||||||
"kyverno-json", "policy_root":
|
|
||||||
"adapters/kyverno-json/policies"}` (REQ-292)
|
|
||||||
- `adapters/kyverno-json/kyverno_json_engine.py` — `KyvernoJsonEngine`
|
|
||||||
implementing the protocol: `is_configured()` guards on `which kj`;
|
|
||||||
`evaluate()` writes payload to temp JSON, invokes
|
|
||||||
`kj scan --policy <dir> --payload <json> --output json`, translates
|
|
||||||
native output → `list[dict]` PCR records (`engine: "kyverno"`,
|
|
||||||
`ruleId` prefixed `KJ_<policy_name>`, severity from
|
|
||||||
`nova.cloudinit.dev/severity` annotation); defensive parsing
|
|
||||||
(malformed → `error` PCR, never exception); `is_configured()==false`
|
|
||||||
→ single `SKIPPED` PCR (`KJ_ENGINE_NOT_CONFIGURED`) (REQ-293)
|
|
||||||
- `adapters/kyverno-json/__init__.py` exports `KyvernoJsonEngine`;
|
|
||||||
`adapters/kyverno-json/policies/_smoke.json` trivial
|
|
||||||
`require-contract-id` policy for round-trip validation;
|
|
||||||
`scripts/install-kyverno-json.sh` runs
|
|
||||||
`go install github.com/kyverno/kyverno-json/cmd/kj@latest`;
|
|
||||||
`.github/workflows/ci.yml` + `.gitea/workflows/ci.yml` install Go + kj
|
|
||||||
(cached) (REQ-294)
|
|
||||||
- `tests/test_policy_engine.py` — protocol conformance, registry
|
|
||||||
selection, unknown-engine `KeyError`, `NullEngine` fallback,
|
|
||||||
`is_configured()` false when `which kj` absent (mocked) (REQ-308)
|
|
||||||
- `tests/test_kyverno_json_engine.py` — `evaluate()` returns PCR dicts
|
|
||||||
validating against `schemas/policy_check_result.schema.json` (via
|
|
||||||
`jsonschema`); defensive parsing (malformed kyverno-json output →
|
|
||||||
`error` PCR); `is_configured()==false` → `SKIPPED` with
|
|
||||||
`KJ_ENGINE_NOT_CONFIGURED`; `pytest.skip("kj not installed")` when
|
|
||||||
`which kj` absent (REQ-309)
|
|
||||||
|
|
||||||
**Vertical slice:** The `PolicyEngineRegistry.get_engine()` returns a
|
**REQs covered:** (prerequisite, not REQ-372 directly — per spec §3.3
|
||||||
configured `KyvernoJsonEngine` that can `evaluate()` a trivial payload
|
Edge 2 + D-242).
|
||||||
against `_smoke.json` and produce a valid PCR list. The confidence
|
|
||||||
signal is unchanged — it already consumes `list[PolicyCheckResult]`.
|
|
||||||
The platform runs with or without the `kj` binary (`is_configured()`
|
|
||||||
guard). All existing tests pass (NullEngine fallback when `policy` key
|
|
||||||
absent in test config — but the v1.25 config.json *sets* the key, so
|
|
||||||
existing tests that use the real config get `KyvernoJsonEngine` with
|
|
||||||
`is_configured()==false` → `SKIPPED`).
|
|
||||||
|
|
||||||
**Files touched:**
|
#### Wave 2 — deck source (ci-doc-writer)
|
||||||
- `core/policy_engine.py` (NEW)
|
|
||||||
- `.ciagent/config.json` (MODIFIED — `policy` object)
|
|
||||||
- `adapters/kyverno-json/__init__.py` (NEW)
|
|
||||||
- `adapters/kyverno-json/kyverno_json_engine.py` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/_smoke.json` (NEW)
|
|
||||||
- `scripts/install-kyverno-json.sh` (NEW)
|
|
||||||
- `.github/workflows/ci.yml` (MODIFIED — Go + kj install step)
|
|
||||||
- `.gitea/workflows/ci.yml` (MODIFIED — Go + kj install step)
|
|
||||||
- `tests/test_policy_engine.py` (NEW)
|
|
||||||
- `tests/test_kyverno_json_engine.py` (NEW)
|
|
||||||
|
|
||||||
**Verification:** `pytest tests/test_policy_engine.py
|
**Task P1.W2.T1:** Author
|
||||||
tests/test_kyverno_json_engine.py tests/test_confidence_signal.py
|
`docs/presentations/nova-leadership-deck-marp.md`:
|
||||||
tests/test_adapter.py tests/test_checkov_adapter.py
|
- **Header comment (REQ-372.9):** an HTML comment at the top (before
|
||||||
tests/test_kyverno_adapter.py -v` (new tests pass or skip-without-kj;
|
frontmatter) naming this deck as the leadership artifact for
|
||||||
existing adapter/confidence tests unchanged). `python3 -m py_compile
|
Infrastructure & Operations, August 2026 presentation date, and
|
||||||
core/policy_engine.py adapters/kyverno-json/kyverno_json_engine.py`.
|
naming `nova-autonomous-cloud-delivery-marp.md` as a related-but-
|
||||||
|
distinct artifact that this deck does not compress or modify.
|
||||||
---
|
- **Frontmatter (cover note):** `marp: true; theme: default; footer:
|
||||||
|
"Nova Platform - Infrastructure & Operations"; paginate: false;
|
||||||
### Phase P2 — contract + stack-IR policies (Wave 2, policy-engineer + backend-engineer)
|
size: 16:9` + a `style:` block using ONLY the 4 S&P tokens
|
||||||
|
(`#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`). **Replace the
|
||||||
**Type:** `feat` (policies + resolver wiring + tests)
|
existing deck's `#2E2E2E` blockquote color with `#1B1B1B`** to
|
||||||
|
satisfy REQ-372.6.
|
||||||
**Requirements:** REQ-295, REQ-296, REQ-297, REQ-298, REQ-299
|
- **7 slides** delimited by `---` on its own line (REQ-372.3). All
|
||||||
|
slides use `##` H2 titles (content slides, white bg, red title bar
|
||||||
|
— per RESEARCH R1 final call for visual consistency).
|
||||||
|
- **On-slide body** per the Slide Content Map in PROJECT.md §v1.30
|
||||||
|
(REQ-372.7). Use `>` blockquotes for the italic callouts, `**...**`
|
||||||
|
bold lead for the slide titles' sub-headings, `-` bullets with `→`
|
||||||
|
prefix for the arrow lines (so they render as proper bullets).
|
||||||
|
- **Speaker notes** per slide as HTML comments `<!-- ... -->` within
|
||||||
|
the slide body before the next `---` (REQ-372.4). Word counts:
|
||||||
|
slides 1/2/4/6 in 150–300; slides 3/5 in 250–400; slide 7 in
|
||||||
|
200–300.
|
||||||
|
- **`[1]` citations** (REQ-372.12): at least one `[1]` in speaker
|
||||||
|
notes of slides 3, 5, 7 (the architecture-load slides), grounding
|
||||||
|
to `docs/vision.md` tenets/anti-goals/boundaries.
|
||||||
|
- **No hex colors** outside the 4 S&P tokens anywhere in the source
|
||||||
|
(REQ-372.6).
|
||||||
|
|
||||||
**Must-haves:**
|
**Must-haves:**
|
||||||
- `adapters/kyverno-json/policies/contract/` — 4 policies over consumer
|
- File exists, parses as valid Marp, exactly 7 `---`-delimited slides.
|
||||||
contract JSON: `require-id-pattern.json`,
|
- Header comment present with all 3 elements (REQ-372.9).
|
||||||
`require-env-in-enum.json`, `require-infrastructure-min-1.json`,
|
- Frontmatter has the exact footer string + `paginate: false`.
|
||||||
`forbid-unknown-fields.json` — each a `ValidatingPolicy` with one
|
- Per-slide speaker-note word counts in band.
|
||||||
`validate.assert` rule using JMESPath against the payload root;
|
- `[1]` present in slides 3, 5, 7 speaker notes.
|
||||||
severity via `nova.cloudinit.dev/severity` annotation (REQ-295)
|
- `grep -oiE '#[0-9A-Fa-f]{6}'` returns only the 4 S&P tokens.
|
||||||
- `core/contract_resolver.py` invokes
|
|
||||||
`PolicyEngineRegistry.get_engine().evaluate(contract_dict,
|
|
||||||
policies/contract/, contract_id)` **before** resolving; failures
|
|
||||||
feed the `policy` input as `fail` PCRs (no resolver exit — confidence
|
|
||||||
signal decides the gate, `--soft-fail` pattern); emits
|
|
||||||
`nova.policy.evaluated` metrics event (REQ-296)
|
|
||||||
- `adapters/kyverno-json/policies/stack-ir/` — 3 policies over
|
|
||||||
resolved Stack IR: `require-tagging-standard.json` (ports
|
|
||||||
`nova_tagging.py` — `nova:owner` + `nova:environment` tags on every
|
|
||||||
`resources[]` entry), `forbid-public-ingress.json` (v1.0 demo rule),
|
|
||||||
`require-encryption-by-default.json` (v1.8 D-encryption-default);
|
|
||||||
`~` modifier iterates `resources[]` (REQ-297)
|
|
||||||
- `core/contract_resolver.py` invokes the engine with the resolved
|
|
||||||
Stack IR and `policies/stack-ir/` **after** resolving; resulting PCRs
|
|
||||||
appended to the contract-policy PCRs; resolver return values and
|
|
||||||
exceptions unchanged (additive) (REQ-298)
|
|
||||||
- `tests/test_stack_ir_policies.py` + `tests/fixtures/stack_ir/` —
|
|
||||||
passing IR (all tags + encryption) + failing IR (missing tags, public
|
|
||||||
ingress, plaintext bucket); each policy in isolation + full dir as
|
|
||||||
bundle; `pytest.skip("kj not installed")` when `which kj` absent
|
|
||||||
(REQ-299)
|
|
||||||
|
|
||||||
**Vertical slice:** A consumer contract passes through the resolver
|
**REQs covered:** REQ-372.1, REQ-372.3, REQ-372.4, REQ-372.6,
|
||||||
and produces two PCR lists (contract policies pre-resolve, stack-IR
|
REQ-372.7, REQ-372.9, REQ-372.12.
|
||||||
policies post-resolve) that feed the confidence signal. A contract
|
|
||||||
with a bad `id` or missing tags produces `fail` PCRs that lower the
|
|
||||||
confidence score. The resolver's existing tests pass unchanged (the
|
|
||||||
policy call is additive — it does not change resolver return values
|
|
||||||
or exceptions).
|
|
||||||
|
|
||||||
**Files touched:**
|
#### Wave 3 — smoke test (ci-cli-engineer)
|
||||||
- `adapters/kyverno-json/policies/contract/require-id-pattern.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/contract/require-env-in-enum.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/contract/forbid-unknown-fields.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json` (NEW)
|
|
||||||
- `core/contract_resolver.py` (MODIFIED — pre/post resolve engine calls)
|
|
||||||
- `tests/test_stack_ir_policies.py` (NEW)
|
|
||||||
- `tests/fixtures/stack_ir/passing.json` (NEW)
|
|
||||||
- `tests/fixtures/stack_ir/failing.json` (NEW)
|
|
||||||
|
|
||||||
**Verification:** `pytest tests/test_contract_resolver.py
|
**Task P1.W3.T1:** Author `scripts/check_leadership_deck.sh`:
|
||||||
tests/test_stack_ir_policies.py tests/test_policy_engine.py -v`
|
- Shebang `#!/usr/bin/env bash`, `set -euo pipefail`, header comment
|
||||||
(existing resolver tests pass; new policy tests pass or skip-without-
|
with Usage + Returns.
|
||||||
kj). `python3 -m py_compile core/contract_resolver.py`.
|
- Assertions (REQ-372.8 a–f):
|
||||||
|
- (a) `docs/presentations/nova-leadership-deck-marp.md` exists.
|
||||||
---
|
- (b) slide count = 7 (count `^---\s*$` lines after frontmatter
|
||||||
|
end, +1; or count `---` separators — careful with frontmatter).
|
||||||
### Phase P3 — plan-JSON policies + meta-orchestration + pipeline wiring (Wave 3, policy-engineer + backend-engineer)
|
- (c) per-slide speaker-note word counts in band (extract `<!-- ...
|
||||||
|
-->` content per slide, `wc -w`; slides 1/2/4/6: 150–300; 3/5:
|
||||||
**Type:** `feat` (plan-JSON policies + meta-policies + run_platform.sh wiring + tests)
|
250–400; 7: 200–300). Exit non-zero on violation.
|
||||||
|
- (d) footer string `Nova Platform - Infrastructure & Operations`
|
||||||
**Requirements:** REQ-300, REQ-301, REQ-302, REQ-303
|
present in source (frontmatter `footer:` directive).
|
||||||
|
- (e) only S&P hex colors in source (`grep -oiE '#[0-9A-Fa-f]{6}'`,
|
||||||
|
`sort -u`, compare to 4-token allow-list).
|
||||||
|
- (f) `docs/presentations/nova-leadership-deck.pptx` exists (hard
|
||||||
|
fail per Q-M4).
|
||||||
|
- Exit 0 on pass, non-zero (1) on fail. Runnable from repo root.
|
||||||
|
- NOT wired as a CI gate (no `.github/workflows/` or
|
||||||
|
`workflows-src/` integration).
|
||||||
|
|
||||||
**Must-haves:**
|
**Must-haves:**
|
||||||
- `adapters/kyverno-json/policies/plan-json/` — 3 policies over
|
- `bash scripts/check_leadership_deck.sh` exits 0 after the deck +
|
||||||
`terraform show -json` output: `forbid-plaintext-secrets.json` (ports
|
PPTX are authored/rendered.
|
||||||
CKV_AWS_41/45/46), `forbid-iam-wildcard.json` (ports CKV_AWS_1/40),
|
- Exits non-zero if any assertion fails (test by temporary
|
||||||
`require-kms-reference.json` (ports CKV_AWS_7/33); JMESPath over
|
mutation).
|
||||||
`planned_values.root_module.resources[]` (REQ-300)
|
|
||||||
- `run_platform.sh` Step 5 gains a parallel kyverno-json pass: after
|
|
||||||
Checkov/Wiz produce raw PCRs, the script runs
|
|
||||||
`kj scan --policy adapters/kyverno-json/policies/plan-json/
|
|
||||||
--payload <tfshow.json> -o json` and pipes through
|
|
||||||
`adapters/kyverno-json/kyverno_json_engine.py` to produce a second
|
|
||||||
PCR list; both lists concatenated and fed to the confidence signal;
|
|
||||||
`nova.policy.evaluated` event with both engine names; when
|
|
||||||
`which kj` is false, logs and proceeds with Checkov/Wiz list only
|
|
||||||
(no hard failure) (REQ-301)
|
|
||||||
- `tests/test_plan_json_policies.py` + `tests/fixtures/plan_json/` —
|
|
||||||
passing plan (no secrets, no wildcard, KMS alias) + failing plan
|
|
||||||
(plaintext password, `Action: "*"`, inline KMS key); policies in
|
|
||||||
isolation + bundle; `tests/test_run_platform_plan_json_policies.py`
|
|
||||||
asserts `run_platform.sh` has the kyverno-json Step 5 block +
|
|
||||||
concatenates PCR lists (script-substring assertion, pattern from
|
|
||||||
`tests/test_pipeline.py:79-95`) (REQ-302)
|
|
||||||
- `adapters/kyverno-json/policies/meta/` — `block-on-any-critical.json`
|
|
||||||
(asserts no PCR in merged list has `severity: critical` + `result:
|
|
||||||
fail`; if any does, emits `fail` PCR `KJ_META_BLOCK_CRITICAL`
|
|
||||||
severity `critical` — declarative source of truth; the
|
|
||||||
`confidence_signal.py` hard-override stays as defense-in-depth per
|
|
||||||
D-119) + `tagging-rules-agree.json` (cross-checks Checkov
|
|
||||||
`NOVA_TAG_NAMING` vs kj `KJ_REQUIRE_TAGGING_STANDARD` by
|
|
||||||
`resourceRef`; divergence emits `error` PCR per D-118);
|
|
||||||
`tests/test_meta_policies.py` (REQ-303)
|
|
||||||
|
|
||||||
**Vertical slice:** `run_platform.sh` Step 5 produces a merged PCR list
|
**REQs covered:** REQ-372.8.
|
||||||
(Checkov/Wiz + kj plan-JSON policies + kj meta-policies over the
|
|
||||||
merged list) that feeds the confidence signal. A plan with a plaintext
|
|
||||||
secret produces two `fail` PCRs (one Checkov, one kj) for the same
|
|
||||||
resource — visible defense-in-depth. A critical finding anywhere
|
|
||||||
produces a `KJ_META_BLOCK_CRITICAL` meta-PCR that the confidence
|
|
||||||
signal's hard-override blocks. The pipeline runs with or without `kj`
|
|
||||||
(graceful skip).
|
|
||||||
|
|
||||||
**Files touched:**
|
#### Wave 4 — render + ship-wave records (backend-engineer + lead-developer)
|
||||||
- `adapters/kyverno-json/policies/plan-json/forbid-plaintext-secrets.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/plan-json/forbid-iam-wildcard.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/plan-json/require-kms-reference.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/meta/block-on-any-critical.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/meta/tagging-rules-agree.json` (NEW)
|
|
||||||
- `scripts/run_platform.sh` (MODIFIED — Step 5 kj parallel pass)
|
|
||||||
- `tests/test_plan_json_policies.py` (NEW)
|
|
||||||
- `tests/test_meta_policies.py` (NEW)
|
|
||||||
- `tests/test_run_platform_plan_json_policies.py` (NEW)
|
|
||||||
- `tests/fixtures/plan_json/passing.json` (NEW)
|
|
||||||
- `tests/fixtures/plan_json/failing.json` (NEW)
|
|
||||||
|
|
||||||
**Verification:** `pytest tests/test_plan_json_policies.py
|
**Task P1.W4.T1 (backend-engineer):** Render the PPTX:
|
||||||
tests/test_meta_policies.py tests/test_run_platform_plan_json_policies.py
|
- `python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md --output docs/presentations/nova-leadership-deck.pptx`
|
||||||
tests/test_pipeline.py -v` (new tests pass or skip-without-kj; existing
|
- Verify: PPTX written with 7 slides, python-pptx raised no
|
||||||
pipeline tests pass). `python3 -m py_compile` on any modified Python.
|
exceptions, footer textbox present on every slide.
|
||||||
Shellcheck on `run_platform.sh` if available.
|
- Run `bash scripts/check_leadership_deck.sh` → exits 0.
|
||||||
|
|
||||||
---
|
**Task P1.W4.T2 (lead-developer):** Append CAP-042 to STATE.md:
|
||||||
|
- CAP-042 row in the capability table: artifact paths
|
||||||
|
(`nova-leadership-deck-marp.md`, `nova-leadership-deck.pptx`),
|
||||||
|
audience (Infrastructure & Operations leadership), single-shot
|
||||||
|
intent, presentation month (August 2026), milestone v1.30 / tag
|
||||||
|
`v1.29.3`.
|
||||||
|
|
||||||
### Phase P4 — regression-gate policies + docs (Wave 4, policy-engineer + data-engineer + lead-developer)
|
**Task P1.W4.T3 (lead-developer):** Record D-241 in PROJECT.md:
|
||||||
|
- D-241 entry in the decisions section: single-shot nature, audience,
|
||||||
**Type:** `feat` (regression policies) + `docs` (adapter READMEs + ARCHITECTURE + STANDARDS + METRICS)
|
August 2026 anchor + November 2026 runway, explicit decision not
|
||||||
|
to compress the existing citizen-developer deck.
|
||||||
**Requirements:** REQ-304, REQ-305, REQ-306, REQ-307
|
|
||||||
|
|
||||||
**Must-haves:**
|
**Must-haves:**
|
||||||
- `adapters/kyverno-json/policies/regression/` — 3 policies over
|
- PPTX exists with 7 slides.
|
||||||
capability-inventory JSON frontmatter: `cap-013-adapter-dedup.json`,
|
- Smoke test exits 0.
|
||||||
`cap-023-metrics-collector.json`, `cap-024-deck-structure.json`;
|
- CAP-042 row in STATE.md.
|
||||||
emit `pass`/`fail` PCRs per capability; the existing
|
- D-241 record in PROJECT.md.
|
||||||
`core/regression_verify.py` is kept (drives the CI gate); the
|
|
||||||
policies are the declarative mirror (REQ-304)
|
|
||||||
- `tests/test_regression_policies.py` +
|
|
||||||
`tests/fixtures/capability_inventory/clean.json` +
|
|
||||||
`tests/fixtures/capability_inventory/drifted.json` — clean (all caps
|
|
||||||
pass) + drifted (duplicate adapter, missing metric status, broken
|
|
||||||
deck arc); regression gate still 287/287 baseline (new tests
|
|
||||||
additive, skip-without-kj) (REQ-305)
|
|
||||||
- `adapters/README.md` gains new kyverno-json adapter row + "Policy
|
|
||||||
Engine Protocol" section (Protocol, registry, swap boundary,
|
|
||||||
how-to-add-OpaEngine); `adapters/kyverno-json/README.md` documents
|
|
||||||
the engine, install path, policy directory layout, 4 policy
|
|
||||||
categories (REQ-306)
|
|
||||||
- `.ciagent/ARCHITECTURE.md` §12.7 (added in RESEARCH) is finalized;
|
|
||||||
`schemas/README.md` notes `engine: "kyverno"` shared by K8s adapter
|
|
||||||
+ kj (distinguished by `ruleId` prefix); `modules/STANDARDS.md`
|
|
||||||
gains "Policy authoring standard" section for module owners;
|
|
||||||
`docs/METRICS.md` notes the policy engine is swappable (Strategic
|
|
||||||
Objective #2 — provable trust via a replaceable substrate) (REQ-307)
|
|
||||||
|
|
||||||
**Vertical slice:** The regression gate's capability checks are now
|
**REQs covered:** REQ-372.2, REQ-372.5, REQ-372.8, REQ-372.10,
|
||||||
declarative policies auditable as artifacts. A new module owner can
|
REQ-372.11.
|
||||||
read `modules/STANDARDS.md` "Policy authoring standard" and write a
|
|
||||||
per-module kyverno-json policy. A new engineer can read
|
|
||||||
`adapters/README.md` "Policy Engine Protocol" and implement an
|
|
||||||
`OpaEngine`. The 287/287 baseline is unchanged.
|
|
||||||
|
|
||||||
**Files touched:**
|
### Phase P2 — final-review-ship (review + audit + milestone ship)
|
||||||
- `adapters/kyverno-json/policies/regression/cap-013-adapter-dedup.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/regression/cap-023-metrics-collector.json` (NEW)
|
|
||||||
- `adapters/kyverno-json/policies/regression/cap-024-deck-structure.json` (NEW)
|
|
||||||
- `tests/test_regression_policies.py` (NEW)
|
|
||||||
- `tests/fixtures/capability_inventory/clean.json` (NEW)
|
|
||||||
- `tests/fixtures/capability_inventory/drifted.json` (NEW)
|
|
||||||
- `adapters/README.md` (MODIFIED — new row + PolicyEngine Protocol section)
|
|
||||||
- `adapters/kyverno-json/README.md` (NEW)
|
|
||||||
- `schemas/README.md` (MODIFIED — engine enum note)
|
|
||||||
- `modules/STANDARDS.md` (MODIFIED — Policy authoring standard section)
|
|
||||||
- `docs/METRICS.md` (MODIFIED — swappable engine narrative)
|
|
||||||
|
|
||||||
**Verification:** `pytest tests/test_regression_policies.py
|
**Goal:** Multi-persona review of the milestone changes, project-
|
||||||
tests/test_kyverno_json_engine.py -v` (new tests pass or skip-without-
|
health audit, and milestone ship (merge to main, tag `v1.29.3` =
|
||||||
kj). Full regression gate `pytest tests/` still at 287/287 baseline +
|
milestone release, delete milestone branches).
|
||||||
new tests (skip without kj). Manual read of `adapters/README.md` +
|
|
||||||
`adapters/kyverno-json/README.md` + `modules/STANDARDS.md` policy
|
|
||||||
section for clarity.
|
|
||||||
|
|
||||||
---
|
**Personas:** lead-developer (review + audit + ship coordination).
|
||||||
|
|
||||||
### Phase P5 — final review + audit + milestone ship (Final Phase)
|
**Tasks:**
|
||||||
|
- Review all v1.30 changes (deck source, render extension, smoke
|
||||||
|
test, STATE.md/PROJECT.md records). Auto-apply P0 fixes; flag P1+
|
||||||
|
for post-hoc review.
|
||||||
|
- Audit: reconstruction test (git log matches `.ciagent/` files),
|
||||||
|
file discipline, branch hygiene, commit discipline.
|
||||||
|
- Ship: merge `phase/02` → `milestone/v1.30-leadership-deck` →
|
||||||
|
`main`, tag `v1.29.3`, create release, delete milestone branches.
|
||||||
|
- Complete: mark REQ-372.1..12 complete in REQUIREMENTS.md, mark
|
||||||
|
v1.30 complete in ROADMAP.md.
|
||||||
|
|
||||||
**Type:** `docs` (review + audit + milestone completion)
|
## Wave dependency graph
|
||||||
|
|
||||||
**Requirements:** All REQ-291..309 (mark complete)
|
```
|
||||||
|
W1 (render_pptx.py extension) ─┐
|
||||||
|
├─→ W4.T1 (render PPTX) ─→ W4.T2/T3 (records)
|
||||||
|
W2 (deck source) ──────────────┤ │
|
||||||
|
│ ↓
|
||||||
|
W3 (smoke test) ───────────────┴────────────────────────→ P1 VERIFY
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
P1 SHIP (v1.29.2)
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
P2 (v1.29.3)
|
||||||
|
```
|
||||||
|
|
||||||
**Must-haves:**
|
W1, W2, W3 are independent (no cross-dependencies at author time).
|
||||||
- `ciagent-review` multi-persona code review across P1..P4
|
W4 depends on W1 + W2 (render needs the extension + the source) +
|
||||||
(lead-developer, backend-engineer, data-engineer, policy-engineer).
|
W3 (smoke test validates the render). W4.T2/T3 (records) depend on
|
||||||
Auto-fix P0; flag P1+ for post-hoc review. If P1+ issues found, fix
|
W4.T1 (render confirms ship readiness).
|
||||||
them in this final phase (not loop back to EXECUTE).
|
|
||||||
- `ciagent-audit` — reconstruction test (git log ↔ `.ciagent/` files),
|
|
||||||
`.ciagent/` file discipline, branch hygiene, commit discipline.
|
|
||||||
Critical issues fixed in this phase.
|
|
||||||
- `ciagent-ship` (milestone) — merge `phase/05-final-review-ship` →
|
|
||||||
`milestone/v1.25-kyverno-json` → `main`; tag `v1.24.5` (= the v1.25
|
|
||||||
release per the prev-minor tagging rule); create Gitea release with
|
|
||||||
full milestone summary (all phases, all requirements); delete all
|
|
||||||
milestone branches (local + remote).
|
|
||||||
- Update `REQUIREMENTS.md` (mark REQ-291..309 complete),
|
|
||||||
`ROADMAP.md` (mark v1.25 complete), `CHECKPOINT.json`
|
|
||||||
(milestone_complete: true), `NORTH_STAR.md` (note Strategic
|
|
||||||
Objective #2 — provable trust via a replaceable policy-engine
|
|
||||||
substrate).
|
|
||||||
|
|
||||||
**Vertical slice:** The v1.25 milestone is complete: kyverno-json is
|
## User-Facing Surface
|
||||||
the primary policy tool, behind a swappable adapter, with policies
|
|
||||||
over all 4 Nova artifacts. Tags v1.24.0..v1.24.5 on the v1.24.x line.
|
|
||||||
The milestone branch merges to main.
|
|
||||||
|
|
||||||
**Verification:** `pytest tests/ -v` full suite passes (287 baseline +
|
- **The PPTX deck** (`docs/presentations/nova-leadership-deck.pptx`)
|
||||||
new tests). `git log --oneline` shows the v1.25 phase commits.
|
— the primary leadership-facing artifact, presented live in August
|
||||||
`git tag` shows v1.24.0..v1.24.5. `git branch` shows no leftover
|
2026.
|
||||||
milestone/phase branches (all deleted post-ship).
|
- **The Marp markdown source**
|
||||||
|
(`docs/presentations/nova-leadership-deck-marp.md`) — the source-
|
||||||
|
of-truth, reproducible via `scripts/render_pptx.py`.
|
||||||
|
- **The smoke test** (`scripts/check_leadership_deck.sh`) — runnable
|
||||||
|
on demand by the PO/presenter to verify deck integrity before
|
||||||
|
presentation.
|
||||||
|
|
||||||
---
|
## Happy Path
|
||||||
|
|
||||||
## Wave ordering (parallelization)
|
**J1 — PO presents the deck live** (from spec §3.2):
|
||||||
|
|
||||||
With `parallelization.enabled: true`, `max_concurrent_agents: 5`,
|
1. PO authors `nova-leadership-deck-marp.md` against the Slide
|
||||||
`min_plans_for_parallel: 2`:
|
Content Map; `bash scripts/check_leadership_deck.sh` exits 0
|
||||||
|
(verifies content). _(REQ-372.1, .4, .6, .7, .8, .12.)_
|
||||||
|
2. PO renders the markdown to PPTX via
|
||||||
|
`python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md --output docs/presentations/nova-leadership-deck.pptx`;
|
||||||
|
visual inspection confirms 7 slides + footer. _(REQ-372.2, .5.)_
|
||||||
|
3. PO presents live to Infrastructure & Operations leadership;
|
||||||
|
speaker notes carry architecture depth. _(REQ-372.4.)_
|
||||||
|
4. PO updates STATE.md with CAP-042 and PROJECT.md with D-241 at the
|
||||||
|
v1.30 ship wave. _(REQ-372.10, .11.)_
|
||||||
|
|
||||||
- **P1 Wave 1:** backend-engineer (protocol + registry + install) ‖
|
## UX Acceptance Criteria
|
||||||
data-engineer (config.json policy object) ‖ policy-engineer (engine
|
|
||||||
adapter + smoke policy). 3 concurrent personas. Merge in order:
|
|
||||||
data-engineer → backend-engineer → policy-engineer.
|
|
||||||
- **P2 Wave 2:** policy-engineer (contract + stack-IR policies) ‖
|
|
||||||
backend-engineer (resolver wiring — depends on P1 registry). 2
|
|
||||||
concurrent. Merge: policy-engineer → backend-engineer (wiring
|
|
||||||
references the policy dirs).
|
|
||||||
- **P3 Wave 3:** policy-engineer (plan-JSON + meta policies) ‖
|
|
||||||
backend-engineer (run_platform.sh wiring — depends on P1 engine +
|
|
||||||
P2 resolver pattern). 2 concurrent. Merge: policy-engineer →
|
|
||||||
backend-engineer.
|
|
||||||
- **P4 Wave 4:** policy-engineer (regression policies) ‖ data-engineer
|
|
||||||
(capability-inventory fixtures) ‖ lead-developer (docs: READMEs,
|
|
||||||
STANDARDS, METRICS). 3 concurrent. Merge: data-engineer →
|
|
||||||
policy-engineer → lead-developer.
|
|
||||||
|
|
||||||
Territory enforcement: `warn` mode (per `config.json
|
1. `bash scripts/check_leadership_deck.sh` exits 0 (all 6 assertions
|
||||||
personas.territory_enforcement: "warn"`). Cross-territory edits
|
a–f pass).
|
||||||
(e.g., backend-engineer touching a policy file) emit a warning, not a
|
2. `docs/presentations/nova-leadership-deck.pptx` exists, has 7
|
||||||
block.
|
slides, and python-pptx raised no exceptions during render.
|
||||||
|
3. Visual review: each slide N (1–7) matches the Slide Content Map
|
||||||
|
in PROJECT.md §v1.30 (on-slide body + speaker notes fingerprint).
|
||||||
|
4. Footer `Nova Platform - Infrastructure & Operations` is visible
|
||||||
|
(right-aligned) on every rendered slide.
|
||||||
|
5. Only S&P theme colors appear in the source markdown.
|
||||||
|
6. `[1]` citations present in slides 3, 5, 7 speaker notes.
|
||||||
|
7. CAP-042 row in STATE.md; D-241 record in PROJECT.md.
|
||||||
|
8. `nova-autonomous-cloud-delivery-marp.md` is unmodified (D-241
|
||||||
|
discrete-artifact constraint).
|
||||||
|
|
||||||
## Requirement → phase → persona matrix
|
## Risks (from RESEARCH + GRILL)
|
||||||
|
|
||||||
| REQ | Phase | Primary persona | Type |
|
| Risk | Mitigation |
|
||||||
|-----|-------|-----------------|------|
|
|---|---|
|
||||||
| REQ-291 | P1 | backend-engineer | feat |
|
| python-pptx render fails on the new frontmatter/style block | The python-pptx path strips frontmatter without reading it; the `style:` block is source-only (Marp CLI). No render risk. |
|
||||||
| REQ-292 | P1 | data-engineer | feat (config) |
|
| Footer textbox overlaps content | Place footer at `SLIDE_H - 0.3"` (bottom margin); content area tops out at ~6.5". No overlap. |
|
||||||
| REQ-293 | P1 | policy-engineer | feat |
|
| Speaker notes word-count band violation | ci-doc-writer counts words per slide during authoring; smoke test (8c) is the gate. |
|
||||||
| REQ-294 | P1 | backend-engineer | feat (install) |
|
| `→` lines render as plain text (not bullets) | Use `- → ...` bullets so the renderer treats them as bullet blocks with the arrow in the text. |
|
||||||
| REQ-295 | P2 | policy-engineer | feat |
|
| `*italic*` in source matches unordered-list regex | Verified in RESEARCH R1: `*italic*` (no space after `*`) does NOT match `[-*+]\s+`. Safe. |
|
||||||
| REQ-296 | P2 | backend-engineer | feat (wiring) |
|
| CAP-024 regression policy collides | Verified in RESEARCH R7: CAP-024 validates fixtures, not deck files. No collision. |
|
||||||
| REQ-297 | P2 | policy-engineer | feat |
|
| `slides.yml` CI interferes | Verified in RESEARCH R8: CI only renders the citizen-developer deck (hardcoded DECK). No interference. |
|
||||||
| REQ-298 | P2 | backend-engineer | feat (wiring) |
|
|
||||||
| REQ-299 | P2 | policy-engineer | test |
|
|
||||||
| REQ-300 | P3 | policy-engineer | feat |
|
|
||||||
| REQ-301 | P3 | backend-engineer | feat (pipeline) |
|
|
||||||
| REQ-302 | P3 | policy-engineer + backend-engineer | test |
|
|
||||||
| REQ-303 | P3 | policy-engineer | feat (meta) |
|
|
||||||
| REQ-304 | P4 | policy-engineer | feat |
|
|
||||||
| REQ-305 | P4 | policy-engineer + data-engineer | test |
|
|
||||||
| REQ-306 | P4 | policy-engineer + lead-developer | docs |
|
|
||||||
| REQ-307 | P4 | lead-developer | docs |
|
|
||||||
| REQ-308 | P1 | backend-engineer | test |
|
|
||||||
| REQ-309 | P1 | policy-engineer | test |
|
|
||||||
+826
-1507
File diff suppressed because it is too large
Load Diff
+872
-2323
File diff suppressed because it is too large
Load Diff
+254
-387
@@ -1,438 +1,305 @@
|
|||||||
# Nova — v1.25 Research Findings
|
# RESEARCH — v1.30 Single-shot Leadership Deck
|
||||||
|
|
||||||
> Phase: research (pre-execution). Milestone: v1.25 (kyverno-json Unified
|
> **Autonomy:** full. Research findings load-bearing for v1.30 PLAN.
|
||||||
> Policy Engine). Status: research. Researcher: ci-researcher.
|
> The research scope is narrow: this is a single-shot presentation
|
||||||
> Autonomy: full.
|
> artifact, not a runtime feature. The research covers (1) the
|
||||||
|
> existing render pipeline's behavior + limits, (2) the smoke-test
|
||||||
|
> script conventions, (3) the Marp frontmatter/footer/speaker-notes
|
||||||
|
> handling, (4) the theme-token enforcement strategy, (5) the
|
||||||
|
> python-pptx install path in this environment, (6) the vision
|
||||||
|
> document grounding for `[1]` citations.
|
||||||
|
|
||||||
## 1. Problem domain
|
---
|
||||||
|
|
||||||
Nova's compliance/policy posture is fragmented across three engines with
|
## R1 — Existing render pipeline (`scripts/render_pptx.py`)
|
||||||
three rule languages and three adapter shapes (see PROJECT.md v1.25
|
|
||||||
"Why" for the full diagnosis). The `PolicyCheckResult` schema
|
|
||||||
(`schemas/policy_check_result.schema.json`) is already the engine-agnostic
|
|
||||||
contract that `core/confidence_signal.py` consumes — the *contract* is
|
|
||||||
right; the *orchestration* is fragmented. There is no single declarative
|
|
||||||
place where "what Nova considers compliant" lives. The K8s-only Kyverno
|
|
||||||
adapter (`adapters/kyverno/`) can't help because it only speaks to K8s
|
|
||||||
manifests and the platform emits Terraform (D-053).
|
|
||||||
|
|
||||||
`kyverno-json` is the correction: a Kyverno-ecosystem runtime that applies
|
**Source:** `scripts/render_pptx.py` (688 lines, REQ-269 v1.23).
|
||||||
Kyverno policies to **any** JSON/YAML payload. It becomes the **unified
|
|
||||||
orchestrator** of compliance checks, behind a swappable `PolicyEngine`
|
|
||||||
protocol so OPA can replace it one day. Checkov and Wiz remain as
|
|
||||||
raw-finding adapters feeding *into* kyverno-json meta-policies.
|
|
||||||
|
|
||||||
## 2. kyverno-json — the engine surface
|
**Behavior:**
|
||||||
|
- Argv: `render_pptx.py [deck-name]` → reads
|
||||||
|
`docs/presentations/{deck}-marp.md`, writes
|
||||||
|
`docs/presentations/{deck}-python.pptx` (lines 677-680). **Does
|
||||||
|
not accept a full path or non-`-marp.md` filename.**
|
||||||
|
- Frontmatter: stripped (lines 62-67) — the Marp `footer:`,
|
||||||
|
`paginate:`, `theme:`, `size:`, `style:` directives are NOT read
|
||||||
|
by the python-pptx path. They are source-only (smoke test checks
|
||||||
|
source; the Marp CLI path in `render_slides.sh` reads them, but
|
||||||
|
that path needs Chromium which is unavailable here).
|
||||||
|
- Slide splitting: `re.split(r"\n---\s*\n", ...)` after frontmatter
|
||||||
|
strip (line 69). Exactly 7 `---`-delimited slides required.
|
||||||
|
- Body parsing (`parse_slide`, lines 360-498):
|
||||||
|
- HTML comments (`<!-- ... -->`) are **skipped entirely** (lines
|
||||||
|
366-379). **Speaker notes are NOT embedded in the PPTX.**
|
||||||
|
Acceptable for REQ-372.4 (smoke test checks source word counts,
|
||||||
|
not PPTX-embedded notes).
|
||||||
|
- Headings `#`/`##` → title (first) or lead (subsequent).
|
||||||
|
- Bold lead `**...**` (own line, exactly 2 `**`) → `lead` block
|
||||||
|
(red, bold).
|
||||||
|
- Blockquotes `>` → `quote` block (grey, italic).
|
||||||
|
- Unordered list `[-*+]\s+...` → `bullet` (level by indent).
|
||||||
|
**`*italic*` (no space after `*`) does NOT match** — safe as
|
||||||
|
plain text.
|
||||||
|
- Ordered list `\d+\.\s+...` → `ordered`.
|
||||||
|
- Tables `| ... |` + separator → `table`.
|
||||||
|
- `→`-prefixed lines → `plain` text (not bullets). Content
|
||||||
|
preserved.
|
||||||
|
- `_strip_inline_emphasis` (lines 209-220): `**bold**`, `*italic*`,
|
||||||
|
`` `code` `` markers are collapsed to plain text in the PPTX.
|
||||||
|
Content is preserved; emphasis styling is lost (acceptable — the
|
||||||
|
PPTX is an editable comparison artifact; REQ-372.7 content match
|
||||||
|
is by visual review).
|
||||||
|
- Theme: hardcoded S&P constants (lines 37-43): `RED=#D6002A`,
|
||||||
|
`BLACK=#1B1B1B`, `WHITE=#FFFFFF`, `GREY_HEADER=#F0F0F0`,
|
||||||
|
`GREY_TEXT=#2E2E2E`, `BODY_TEXT=#1B1B1B`. **Note: `GREY_TEXT=#2E2E2E`
|
||||||
|
is a 5th color used internally for blockquote/body text.** This is
|
||||||
|
a renderer-internal color, NOT a source hex color — REQ-372.6
|
||||||
|
scopes to "color values extracted from the source markdown (Marp
|
||||||
|
directives + inline overrides)", so `#2E2E2E` in the renderer does
|
||||||
|
not violate REQ-372.6. The smoke test checks the *source* file for
|
||||||
|
hex colors.
|
||||||
|
- Footer: **NOT rendered.** No footer textbox is added by the
|
||||||
|
existing renderer. **D-242 extension required** to add a
|
||||||
|
right-aligned footer textbox on every slide.
|
||||||
|
- Title slide: `render_title_slide` (line 501) — black bg, red top
|
||||||
|
bar, white title. Triggered when `idx==0` + (`title_is_h1` or
|
||||||
|
`is_title_class`). The leadership deck's slide 1 uses a bold lead
|
||||||
|
(`**The friction...**`) as the first line — this is an H1 (`# The
|
||||||
|
friction...`) in the source, so slide 1 renders as a title slide
|
||||||
|
(black bg). **Decision for PLAN:** author slide 1 with `#` H1
|
||||||
|
title (title slide, black bg, red bar — strong opener) OR author
|
||||||
|
as `##` H2 (content slide, white bg). The Slide Content Map shows
|
||||||
|
slide 1 with a bold title + italic subtitle + arrows + italic
|
||||||
|
closing — a content-rich slide. **Recommend: `##` H2 title for all
|
||||||
|
7 slides → all render as content slides (white bg, red title bar)
|
||||||
|
for visual consistency.** Slide 1 as a black-bg title slide would
|
||||||
|
hide the `→` arrows in white-on-black, which is fine but differs
|
||||||
|
from the map's framing. The map doesn't specify background; visual
|
||||||
|
review accepts either. **Final call in PLAN:** all `##` content
|
||||||
|
slides for consistency + readability of the 3-pattern frame.
|
||||||
|
|
||||||
### 2.1 What it is
|
**Gaps for v1.30 (D-242 extension):**
|
||||||
|
1. Accept explicit source `.md` path + `--output` filename.
|
||||||
|
2. Add right-aligned footer textbox on every slide with exact string
|
||||||
|
`Nova Platform - Infrastructure & Operations`.
|
||||||
|
|
||||||
[kyverno-json](https://github.com/kyverno/kyverno-json) is a standalone Go
|
---
|
||||||
binary from the Kyverno project. It is a **separate runtime** from the
|
|
||||||
Kyverno K8s admission controller — same policy lineage, different
|
|
||||||
application target. Where Kyverno (K8s) evaluates `ClusterPolicy`
|
|
||||||
resources against Kubernetes manifests at admission time, kyverno-json
|
|
||||||
evaluates `ValidatingPolicy` resources against **any** JSON or YAML
|
|
||||||
payload file via the CLI (`kj scan`) or a Go library. It is **not** a
|
|
||||||
Python package (no PyPI release); it is installed via
|
|
||||||
`go install github.com/kyverno/kyverno-json/cmd/kj@latest` (D-115) or by
|
|
||||||
downloading a pinned binary from GitHub releases.
|
|
||||||
|
|
||||||
### 2.2 CLI surface (the v1.25 invocation path)
|
## R2 — Smoke-test script conventions
|
||||||
|
|
||||||
The v1.25 engine uses the `kj scan` subcommand:
|
**Source:** `scripts/check_north_star_diff.sh` (REQ-204), other
|
||||||
|
`scripts/check_*.sh` / `scripts/run_*.sh`.
|
||||||
|
|
||||||
```
|
**Conventions:**
|
||||||
kyverno-json scan [flags]
|
- Shebang `#!/usr/bin/env bash`
|
||||||
|
- Header comment with purpose + Usage + Returns
|
||||||
|
- `set -euo pipefail`
|
||||||
|
- Exit 0 on pass, non-zero (1) on fail
|
||||||
|
- `echo "WARN: ..."` / `echo "ERROR: ..."` to stderr
|
||||||
|
- Runnable from repo root: `bash scripts/check_*.sh`
|
||||||
|
|
||||||
Flags:
|
**v1.30 smoke test (`scripts/check_leadership_deck.sh`) assertions
|
||||||
--labels strings Labels selectors for policies
|
(REQ-372.8 a–f):**
|
||||||
--output string Output format (text or json) (default "text")
|
- (a) `docs/presentations/nova-leadership-deck-marp.md` exists
|
||||||
--payload string Path to payload (json or yaml file)
|
- (b) slide count = 7 (count `---` separators on own line, excluding
|
||||||
--policy strings Path to kyverno-json policies
|
frontmatter)
|
||||||
--pre-process strings JMESPath expression used to pre process payload
|
- (c) per-slide speaker-note word counts in band (extract HTML
|
||||||
```
|
comments per slide; slides 1/2/4/6: 150–300; 3/5: 250–400; 7:
|
||||||
|
200–300)
|
||||||
|
- (d) footer string `Nova Platform - Infrastructure & Operations`
|
||||||
|
present in source (frontmatter `footer:` directive)
|
||||||
|
- (e) only S&P hex colors `#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`
|
||||||
|
in source (grep for `#[0-9A-Fa-f]{6}` and diff against the allow-
|
||||||
|
list)
|
||||||
|
- (f) `docs/presentations/nova-leadership-deck.pptx` exists (hard
|
||||||
|
fail per Q-M4)
|
||||||
|
|
||||||
The `KyvernoJsonEngine.evaluate()` implementation (REQ-293) invokes:
|
**Implementation approach:** pure bash + `grep`/`awk`/`wc`. No
|
||||||
```
|
python dependency for the smoke test (keeps it runnable on demand
|
||||||
kj scan --policy <policy_dir> --payload <payload.json> --output json
|
without the python-pptx install). Slide count: count lines matching
|
||||||
```
|
`^---\s*$` after the frontmatter, +1. Speaker notes: per slide,
|
||||||
and parses the JSON `results[]` array. The `--pre-process` flag is
|
extract content between `<!--` and `-->`, strip HTML comment markers,
|
||||||
available for JMESPath pre-projection (noted for the meta-policy use case
|
`wc -w`. Color scan: `grep -oiE '#[0-9A-Fa-f]{6}'` on the source,
|
||||||
where the payload is the merged PCR list and a pre-process expression
|
sort -u, compare to allow-list.
|
||||||
can index by `ruleId` — recorded as a future optimization, not used in
|
|
||||||
v1.25's initial implementation).
|
|
||||||
|
|
||||||
Other subcommands (`kj jp`, `kj serve`, `kj playground`, `kj docs`) are
|
---
|
||||||
out of scope for v1.25. `kj serve` is the long-running web-app mode
|
|
||||||
(noted as a future consideration for lower-latency evaluation in the
|
|
||||||
Out of Scope section of REQUIREMENTS.md). `kj jp` is the JMESPath REPL —
|
|
||||||
useful for policy authoring/debugging, not invoked by the engine.
|
|
||||||
|
|
||||||
### 2.3 Policy structure (the `ValidatingPolicy` resource)
|
## R3 — Marp frontmatter / footer / speaker-notes handling
|
||||||
|
|
||||||
kyverno-json policies are Kubernetes-style resources (cluster-scoped)
|
**Source:** `docs/presentations/nova-autonomous-cloud-delivery-marp.md`
|
||||||
belonging to the `json.kyverno.io` API group, kind `ValidatingPolicy`,
|
(lines 1-27), Marp CLI v4.5.0 (available via npx).
|
||||||
version `v1alpha1`:
|
|
||||||
|
|
||||||
|
**Existing deck frontmatter:**
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: json.kyverno.io/v1alpha1
|
marp: true
|
||||||
kind: ValidatingPolicy
|
theme: default
|
||||||
metadata:
|
paginate: true
|
||||||
name: <policy-name> # becomes the KJ_<policy-name> ruleId prefix
|
size: 16x9
|
||||||
spec:
|
footer: 'Nova — The Autonomous Cloud Delivery Platform'
|
||||||
rules:
|
style: |
|
||||||
- name: <rule-name>
|
section { ... color: #1B1B1B; ... }
|
||||||
identifier: <jmespath> # optional — path to the unique entry id
|
h1 { color: #D6002A; ... }
|
||||||
match: # assertion tree — which payload entries
|
...
|
||||||
any: # the rule applies to
|
|
||||||
- <assertion>
|
|
||||||
exclude: # optional — exclude matching entries
|
|
||||||
any:
|
|
||||||
- <assertion>
|
|
||||||
context: # optional — named bindings available to
|
|
||||||
- name: <binding> # the rule's assertions ($<binding>)
|
|
||||||
variable: <value>
|
|
||||||
validate:
|
|
||||||
message: "<human-readable>" # optional per-rule message
|
|
||||||
assert:
|
|
||||||
all: # all assertions must hold
|
|
||||||
- check: <assertion-tree>
|
|
||||||
message: "<per-check>"
|
|
||||||
# OR
|
|
||||||
any: # at least one assertion must hold
|
|
||||||
- check: <assertion-tree>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Key differences from K8s Kyverno policies:
|
**v1.30 leadership deck frontmatter (per cover note + spec):**
|
||||||
- **Always cluster-scoped** — no `namespace` field.
|
|
||||||
- **No `forEach`, pattern operators, anchors, or wildcards.** Iteration
|
|
||||||
is done via the `~` projection modifier in assertion trees (see §2.4).
|
|
||||||
- **Assertion trees** with JMESPath expressions replace Kyverno's
|
|
||||||
pattern-matching syntax (see §2.4).
|
|
||||||
|
|
||||||
### 2.4 Assertion trees (the rule language)
|
|
||||||
|
|
||||||
An `assert` declaration contains an `all` or `any` list. Each entry has a
|
|
||||||
`check` (the assertion tree — a nested JMESPath projection) and an
|
|
||||||
optional `message`. **All comparisons happen in the leaves of the tree.**
|
|
||||||
|
|
||||||
A simple example (assert a pod doesn't use the default service account):
|
|
||||||
```yaml
|
```yaml
|
||||||
validate:
|
marp: true
|
||||||
assert:
|
theme: default
|
||||||
all:
|
footer: "Nova Platform - Infrastructure & Operations"
|
||||||
- message: "serviceAccountName 'default' is not allowed"
|
paginate: false
|
||||||
check:
|
size: 16x9
|
||||||
spec:
|
style: |
|
||||||
(serviceAccountName == 'default'): false
|
section { font-family: "Akkurat Pro", "Helvetica Neue", "Arial", sans-serif; font-size: 22px; color: #1B1B1B; padding: 48px 56px 40px; overflow: auto; }
|
||||||
|
h1 { color: #D6002A; font-size: 34px; margin-bottom: 0.3em; }
|
||||||
|
h2 { color: #D6002A; font-size: 26px; margin-bottom: 0.2em; }
|
||||||
|
blockquote { border-left: 4px solid #D6002A; color: #1B1B1B; font-size: 20px; padding-left: 12px; }
|
||||||
|
strong { color: #D6002A; }
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
The `(expression)` syntax evaluates a JMESPath expression; the result
|
**Key differences from the existing deck:**
|
||||||
becomes the current object for descendants; the leaf value is compared
|
- `paginate: false` (existing: `true`) — per cover note.
|
||||||
to the expected value.
|
- `footer: "Nova Platform - Infrastructure & Operations"` (existing:
|
||||||
|
different string) — per cover note + REQ-372.5.
|
||||||
|
- The `style:` block uses only the 4 S&P tokens. The existing deck's
|
||||||
|
`style:` uses `#2E2E2E` for blockquote color — **this must be
|
||||||
|
changed to `#1B1B1B`** in the leadership deck's `style:` block to
|
||||||
|
satisfy REQ-372.6 (only 4 hex colors in source). The renderer's
|
||||||
|
internal `GREY_TEXT=#2E2E2E` is not in the source, so it doesn't
|
||||||
|
violate REQ-372.6 — but the *source* `style:` block must not
|
||||||
|
contain `#2E2E2E`.
|
||||||
|
|
||||||
**Iteration via the `~` modifier.** The `~` prefix on a key applies
|
**Speaker notes:** HTML comments `<!-- ... -->` within the slide
|
||||||
descendant assertions to **each element** of an array/map individually
|
body, before the next `---`. The Marp CLI renders these as speaker
|
||||||
(rather than comparing the whole array). Given `foo.bar: [1,2,3]`:
|
notes in the HTML/PPTX; the python-pptx path skips them. The smoke
|
||||||
```yaml
|
test extracts them from the *source* for word-count checking.
|
||||||
check:
|
|
||||||
foo:
|
|
||||||
~.bar: # iterate each element
|
|
||||||
(@ < `5`): true # assert each element < 5
|
|
||||||
```
|
|
||||||
The `~index_name.bar` form binds the index (array) or key (map) to
|
|
||||||
`$index_name` for use in descendants. This is how v1.25 iterates
|
|
||||||
`resources[]` in the Stack IR policies (REQ-297) and
|
|
||||||
`planned_values.root_module.resources[]` in the plan-JSON policies
|
|
||||||
(REQ-300).
|
|
||||||
|
|
||||||
**Explicit bindings** via `->binding_name` allow descendants to refer
|
---
|
||||||
to a parent node via `$binding_name`. Built-in bindings: `$payload`
|
|
||||||
(the whole input), `$policy`, `$rule`.
|
|
||||||
|
|
||||||
**Escaping** via `\key\` prevents projection when a payload key collides
|
## R4 — Theme-token enforcement strategy
|
||||||
with the projection syntax. Not needed for Nova payloads (no `(key)`
|
|
||||||
fields), noted for completeness.
|
|
||||||
|
|
||||||
### 2.5 Output shape (what `kj scan --output json` produces)
|
**REQ-372.6:** only `#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0` as hex
|
||||||
|
colors in the source.
|
||||||
|
|
||||||
The JSON output is a `results[]` array. Each result entry has (at
|
**Enforcement:**
|
||||||
minimum):
|
1. **Source `style:` block:** use only the 4 tokens. Replace the
|
||||||
- `policy`: the policy metadata.name
|
existing deck's `#2E2E2E` (blockquote color) with `#1B1B1B`.
|
||||||
- `rule`: the rule name
|
2. **No inline `color:` overrides** in slide bodies — the slides use
|
||||||
- `result`: `"pass"` | `"fail"` | `"error"` | `"skip"` (lowercase)
|
no inline HTML/color spans.
|
||||||
- `message`: the assertion message (or engine error message)
|
3. **Smoke test (8e):** `grep -oiE '#[0-9A-Fa-f]{6}'` on the source,
|
||||||
- `resource`: the matched payload entry (the `identifier` value, or the
|
`sort -u`, compare to the 4-token allow-list. Any other hex color
|
||||||
whole payload when no identifier/match)
|
→ fail.
|
||||||
- `namespace`/`kind`/`name`: K8s-style fields (present but empty for
|
|
||||||
non-K8s payloads — the K8s Kyverno adapter's evidence uses these; the
|
|
||||||
kyverno-json engine's evidence uses `assertion`/`jmespath` instead)
|
|
||||||
- `severity`: not present by default (kyverno-json does not assign
|
|
||||||
severities — the Nova policy author assigns severity via a Nova-
|
|
||||||
specific annotation; see §2.6)
|
|
||||||
|
|
||||||
The `KyvernoJsonEngine._to_pcr()` translator (REQ-293) maps:
|
---
|
||||||
- `policy` → `ruleId` (prefixed `KJ_<policy_name>` per D-116)
|
|
||||||
- `result` → `result` (`pass`/`fail`/`error` → pass/fail/error;
|
|
||||||
`skip`/`skipped` → skipped)
|
|
||||||
- `message` → `message`
|
|
||||||
- `resource` → `resourceRef` + `evidence.resource`
|
|
||||||
- severity from the policy's `metadata.annotations` (see §2.6)
|
|
||||||
- `engine: "kyverno"` (per D-116 — no new enum value)
|
|
||||||
|
|
||||||
### 2.6 Severity assignment (Nova convention)
|
## R5 — python-pptx install path (this environment)
|
||||||
|
|
||||||
kyverno-json does not natively assign severities to results. Nova's
|
**Environment:** Debian/Ubuntu, Python 3.11.2, no system pip, no
|
||||||
confidence signal requires a `severity` per PCR (critical/high/medium/
|
root, no `python3-venv`/`python3-pip` packages, no `ensurepip`.
|
||||||
low/info). The v1.25 convention: each Nova policy file declares its
|
|
||||||
severity via a `metadata.annotations` field:
|
|
||||||
|
|
||||||
```yaml
|
**Resolved install path:**
|
||||||
metadata:
|
1. `curl -sS https://bootstrap.pypa.io/get-pip.py -o /home/opencode/tmp/get-pip.py`
|
||||||
name: forbid-public-ingress
|
2. `python3 get-pip.py --user --break-system-packages`
|
||||||
annotations:
|
3. `/home/opencode/.local/bin/pip install --user --break-system-packages "python-pptx>=0.6.23"`
|
||||||
nova.cloudinit.dev/severity: high
|
4. `pip install --user --break-system-packages "pytest>=8.0"` (for
|
||||||
```
|
verify stage)
|
||||||
|
|
||||||
The `KyvernoJsonEngine._to_pcr()` reads this annotation from the loaded
|
**Result:** python-pptx 1.0.2 + pytest 9.1.1 installed to user-site.
|
||||||
policy YAML (not from the scan result — the result doesn't carry it) and
|
`python3 -c "import pptx"` succeeds. No Chromium needed (python-pptx
|
||||||
applies it to every result that policy produces. Default when absent:
|
is the render path, not Marp CLI PPTX).
|
||||||
`info`. This keeps severity in the policy (declarative, version-
|
|
||||||
controlled) rather than in the engine adapter (imperative). The
|
|
||||||
annotation key is `nova.cloudinit.dev/severity` (matches the existing
|
|
||||||
`nova.cloudinit.dev` namespace used in `schemas/tagging-standard.json`).
|
|
||||||
|
|
||||||
## 3. The four policy targets (v1.25 scope)
|
**Confirmed in RESEARCH execution:** all commands ran successfully
|
||||||
|
in this session.
|
||||||
|
|
||||||
### 3.1 Consumer contract JSON (REQ-295)
|
---
|
||||||
|
|
||||||
The payload is the parsed contract dict (the raw YAML loaded as JSON).
|
## R6 — Vision document grounding for `[1]` citations
|
||||||
Policies assert the `contract.schema.json` constraints declaratively:
|
|
||||||
`require-id-pattern` (JMESPath regex `^[a-z][a-z0-9-]{2,5}$` over
|
|
||||||
`id`), `require-env-in-enum` (`environment` in `["dev","qa","prod","dr"]`),
|
|
||||||
`require-infrastructure-min-1` (`length(infrastructure) > 0`),
|
|
||||||
`forbid-unknown-fields` (keys subset of the 4 allowed). These are the
|
|
||||||
declarative equivalent of the jsonschema constraints — they let Nova
|
|
||||||
apply its own compliance posture (e.g. forbid a specific env for a
|
|
||||||
specific consumer) on top of schema validity without editing the
|
|
||||||
jsonschema.
|
|
||||||
|
|
||||||
**Invocation point:** `core/contract_resolver.py` pre-resolve (REQ-296).
|
**Source:** `docs/vision.md` (the spec's `acdl-vision.md` / `[1]`
|
||||||
Early-fail: if a contract policy fails, the resolver still proceeds
|
reference).
|
||||||
(the confidence signal decides the gate, consistent with the existing
|
|
||||||
`--soft-fail` Checkov pattern) — but the failing PCRs are in the
|
|
||||||
`policy` input, which lowers the score.
|
|
||||||
|
|
||||||
### 3.2 Resolved Target Stack IR JSON (REQ-297)
|
**Key tenets for slide grounding:**
|
||||||
|
- **§1 The Friction** (slide 1): "Software delivery scales with the
|
||||||
|
coordination surface around it, not the engineering inside it."
|
||||||
|
Grounds the three-pattern problem frame + binding-constraint
|
||||||
|
claim.
|
||||||
|
- **§3 Core Tenets** (slides 3, 5, 7):
|
||||||
|
- "The Delivery Lifecycle is a Sovereign Boundary" — grounds
|
||||||
|
slide 3's Sovereign boundary tenet + slide 5's boundary
|
||||||
|
discipline + slide 7's "Nova stays in its lane."
|
||||||
|
- "Lower Environments are Autonomous; Higher Environments are
|
||||||
|
Attested" — grounds slide 3's Lower autonomous · higher attested
|
||||||
|
tenet + slide 4's HITL discipline.
|
||||||
|
- "Infrastructure is Consumed, Not Maintained" — grounds slide 5's
|
||||||
|
"VM, bare-metal, OS lifecycles" exclusion.
|
||||||
|
- **§4 Domain Boundaries** (slides 2, 5, 6): "The platform begins
|
||||||
|
where the artifact is compiled and ends where it runs in
|
||||||
|
production." "Out of scope: Application business logic, IDE
|
||||||
|
workflows, product backlog management, sprint planning, compute
|
||||||
|
requiring node-level or OS-level management." Grounds slide 5's
|
||||||
|
in-lane/out-of-lane split + slide 6's "Nova absorbs no IDE, no
|
||||||
|
editor, no sprint tool, no agent harness."
|
||||||
|
|
||||||
The payload is the resolved Stack IR dict produced by
|
**Citation convention:** `[1]` in speaker notes, resolving to
|
||||||
`core/contract_resolver.py` (the merged module outputs). Policies
|
`docs/vision.md`. The spec §citation-references confirms `[1]` →
|
||||||
assert over `resources[]` (the array of resolved resources):
|
`acdl-vision.md` (vision document, source [1]).
|
||||||
`require-tagging-standard` (every resource's `tags` has `nova:owner` +
|
|
||||||
`nova:environment` — ports
|
|
||||||
`adapters/terraform/policy/custom_rules/nova_tagging.py`),
|
|
||||||
`forbid-public-ingress` (no resource has `public_ingress: true` — the
|
|
||||||
v1.0 demo rule, now declarative), `require-encryption-by-default` (every
|
|
||||||
S3/EBS/KMS-aliased resource carries encryption config — ports the v1.8
|
|
||||||
D-encryption-default rule). The `~` modifier iterates `resources[]`.
|
|
||||||
|
|
||||||
**Invocation point:** `core/contract_resolver.py` post-resolve (REQ-298).
|
---
|
||||||
Additive — the resolver's return values and exceptions are unchanged;
|
|
||||||
the PCRs are appended to the contract-policy PCRs.
|
|
||||||
|
|
||||||
### 3.3 Terraform plan JSON (REQ-300)
|
## R7 — CAP-024 regression policy (collision check)
|
||||||
|
|
||||||
The payload is `terraform show -json <tfplan>` output. Policies assert
|
**Source:** `adapters/kyverno-json/policies/regression/cap-024-deck-structure.json`
|
||||||
over `planned_values.root_module.resources[]`:
|
+ `tests/test_regression_policies.py`.
|
||||||
`forbid-plaintext-secrets` (no `aws_db_instance.password` /
|
|
||||||
`aws_iam_user.login_profile.password` in plaintext — ports
|
|
||||||
`CKV_AWS_41/45/46`), `forbid-iam-wildcard` (no `Action: "*"` or
|
|
||||||
`Resource: "*"` in `aws_iam_policy.PolicyDocument` — ports
|
|
||||||
`CKV_AWS_1/40`), `require-kms-reference` (KMS keys referenced by alias,
|
|
||||||
not inline key material — ports `CKV_AWS_7/33`). These are declarative
|
|
||||||
**mirrors** of `checkov_adapter.py:RULE_MAP` — the Checkov rule stays
|
|
||||||
the source of truth for `terraform_plan` scanning; the kyverno-json
|
|
||||||
policy covers the same plan JSON with a different rule language
|
|
||||||
(defense-in-depth against engine drift).
|
|
||||||
|
|
||||||
**Invocation point:** `run_platform.sh` Step 5 (REQ-301). After
|
**Finding:** CAP-024 validates the citizen-developer deck's 4-beat
|
||||||
Checkov/Wiz produce raw PCRs, the script runs `kj scan` over the plan
|
arc (Problem/Solution/Proof/Roadmap+Ask) against fixture files
|
||||||
JSON; both PCR lists concatenate into the confidence signal's `policy`
|
(`clean.json`/`drifted.json` in `tests/fixtures/`), NOT against the
|
||||||
input. When `which kj` is false, the script logs and proceeds with the
|
actual deck markdown files. The leadership deck
|
||||||
Checkov/Wiz list only.
|
(`nova-leadership-deck-marp.md`) does NOT pass through this policy.
|
||||||
|
No collision risk. The leadership deck's 7-slide structure is a
|
||||||
|
different artifact (CAP-042, not CAP-024).
|
||||||
|
|
||||||
### 3.4 PolicyCheckResult records (meta-policies, REQ-303)
|
---
|
||||||
|
|
||||||
The payload is the **merged** `list[PolicyCheckResult]` produced by
|
## R8 — `slides.yml` CI (non-interference check)
|
||||||
checkov + wiz + the plan-JSON policies. This is the most novel target —
|
|
||||||
kyverno-json policies over the policy results themselves.
|
|
||||||
`block-on-any-critical` asserts no PCR has `severity: "critical"` +
|
|
||||||
`result: "fail"`; if any does, the meta-policy emits a `fail` PCR with
|
|
||||||
`ruleId: "KJ_META_BLOCK_CRITICAL"` and severity `critical`. This is the
|
|
||||||
declarative source of truth for "critical = block" (D-119 — the
|
|
||||||
`confidence_signal.py` `PENALTY["critical"]: None` hard-override stays
|
|
||||||
as defense-in-depth). `tagging-rules-agree` cross-checks the Checkov
|
|
||||||
`NOVA_TAG_NAMING` result against the kyverno-json
|
|
||||||
`KJ_REQUIRE_TAGGING_STANDARD` result by `resourceRef`; divergence emits
|
|
||||||
an `error` PCR (D-118).
|
|
||||||
|
|
||||||
**Invocation point:** after the three target policies (contract/stack-
|
**Source:** `workflows-src/slides.yml`.
|
||||||
IR/plan-JSON) produce their PCR lists, the merged list is the payload
|
|
||||||
for the meta-policies. The meta-policy PCRs are appended to the merged
|
|
||||||
list, which is what the confidence signal consumes.
|
|
||||||
|
|
||||||
## 4. The `PolicyEngine` swap boundary
|
**Finding:** The CI workflow triggers on `docs/presentations/**`
|
||||||
|
path changes, but `scripts/render_slides.sh` defaults to
|
||||||
|
`DECK="nova-autonomous-cloud-delivery"` and only renders that one
|
||||||
|
deck. Adding `nova-leadership-deck-marp.md` to
|
||||||
|
`docs/presentations/` will trigger the CI, but it will only re-
|
||||||
|
render the citizen-developer deck (no-op if that deck is unchanged).
|
||||||
|
The leadership deck is NOT rendered by CI (per spec: no CI gate, no
|
||||||
|
`publish.yml` integration). **No interference.** The bot commit from
|
||||||
|
CI (if any) will be a no-op re-render of the unchanged citizen-
|
||||||
|
developer deck.
|
||||||
|
|
||||||
### 4.1 Protocol shape (REQ-291)
|
---
|
||||||
|
|
||||||
A Python `Protocol` (PEP 544 — structural subtyping, no inheritance):
|
## Persona assessment (lead-developer)
|
||||||
```python
|
|
||||||
class PolicyEngine(Protocol):
|
|
||||||
@property
|
|
||||||
def name(self) -> str: ...
|
|
||||||
def is_configured(self) -> bool: ...
|
|
||||||
def evaluate(self, payload: dict | str, policy_dir: Path,
|
|
||||||
contract_id: str) -> list[dict]: ...
|
|
||||||
```
|
|
||||||
`list[dict]` (not `list[PolicyCheckResult]` — there's no dataclass; the
|
|
||||||
schema is enforced via `jsonschema` validation in tests, matching the
|
|
||||||
existing adapter pattern). The registry selects the active engine from
|
|
||||||
`config.json.policy.engine`. A `NullEngine` is the fallback when the
|
|
||||||
`policy` key is absent (emits `SKIPPED` — backward compatibility for
|
|
||||||
tests that don't set the key).
|
|
||||||
|
|
||||||
### 4.2 The OPA-equivalent surface (future swap)
|
**Active personas for v1.30:**
|
||||||
|
- **lead-developer** (coordination) — owns STATE.md CAP-042, PROJECT.md
|
||||||
|
D-241, milestone coordination. Territory: `.ciagent/STATE.md`,
|
||||||
|
`.ciagent/PROJECT.md`.
|
||||||
|
- **backend-engineer** (backend) — owns `scripts/render_pptx.py`
|
||||||
|
extension + PPTX render + python-pptx install. Territory:
|
||||||
|
`scripts/render_pptx.py`, `docs/presentations/nova-leadership-deck.pptx`.
|
||||||
|
Framework override: python-pptx (not fastify/hono — the default
|
||||||
|
frameworks don't match this project's Python stack).
|
||||||
|
- **ci-doc-writer** (custom, phase-specific) — owns the Marp markdown
|
||||||
|
deck source. Territory:
|
||||||
|
`docs/presentations/nova-leadership-deck-marp.md`. Created for this
|
||||||
|
phase (presentation authoring); removed after P1.
|
||||||
|
- **ci-cli-engineer** (custom) — owns the smoke-test script.
|
||||||
|
Territory: `scripts/check_leadership_deck.sh`.
|
||||||
|
|
||||||
OPA (Open Policy Agent) is the most likely future replacement. The
|
**Deactivated personas:**
|
||||||
mapping:
|
- **frontend-engineer** — already `active: false` in config (no UI).
|
||||||
| Nova `PolicyEngine` member | kyverno-json impl | OPA equivalent |
|
Confirmed.
|
||||||
|---|---|---|
|
- **data-engineer** — no schema/migration work in this milestone.
|
||||||
| `name` | `"kyverno-json"` | `"opa"` |
|
Deactivate for v1.30.
|
||||||
| `is_configured()` | `which kj` | `which opa` |
|
|
||||||
| `evaluate(payload, policy_dir, contract_id)` | `kj scan --policy <dir> --payload <json> -o json` | `opa eval -d <dir> -i <json> 'data.nova.<...>'` |
|
|
||||||
| Policy file format | `ValidatingPolicy` (YAML) | Rego (`.rego`) |
|
|
||||||
| Result shape | `results[]` (pass/fail/error/skip) | `result` (set of violations) |
|
|
||||||
| Severity | Nova annotation `nova.cloudinit.dev/severity` | Nova convention (Rego `metadata` or a wrapper) |
|
|
||||||
|
|
||||||
The protocol is minimal (3 members) specifically so the OPA
|
**Territory enforcement:** `warn` (per config).
|
||||||
implementation is a known quantity: an `OpaEngine` class that shells to
|
|
||||||
`opa eval`, translates the Rego violation set to PCR dicts, and
|
|
||||||
implements `is_configured()` via `which opa`. The policy *files* would
|
|
||||||
need rewriting (Rego, not ValidatingPolicy) — but the protocol, the
|
|
||||||
registry, the confidence signal, and the PCR schema are all untouched.
|
|
||||||
This is the swap boundary the user asked for ("Implemented as an
|
|
||||||
adapter since we might one day decide to replace it with something else
|
|
||||||
like OPA").
|
|
||||||
|
|
||||||
### 4.3 Why not a full plugin registry?
|
|
||||||
|
|
||||||
A `setuptools` entry-point plugin registry (like checkov's
|
|
||||||
`--external-checks-dir`) was considered and rejected: Nova has 1 active
|
|
||||||
engine today (kyverno-json) and at most 2 in the foreseeable future
|
|
||||||
(kyverno-json + OPA). A `Protocol` + `dict` registry in
|
|
||||||
`core/policy_engine.py` is the right weight — discoverable, typed,
|
|
||||||
testable, and ~40 lines. An entry-point registry adds packaging
|
|
||||||
complexity (entry-point metadata, version resolution) for no gain at
|
|
||||||
this scale. The `register(name, factory)` method on the registry is
|
|
||||||
the extension point if a future milestone needs runtime plugin
|
|
||||||
discovery.
|
|
||||||
|
|
||||||
## 5. Latency / MTTR impact (G-Q3 anticipation)
|
|
||||||
|
|
||||||
NORTH_STAR.md MTTR target: < 60s p95. `run_platform.sh` Step 5 today
|
|
||||||
runs Checkov over the terraform plan (typically 5-15s for a small
|
|
||||||
stack). Adding `kj scan` over the same plan JSON adds:
|
|
||||||
- Process spawn: ~50ms (Go binary startup)
|
|
||||||
- Policy load: ~20ms (a handful of YAML files)
|
|
||||||
- Assertion evaluation: ~100-500ms (JMESPath over a small plan)
|
|
||||||
- Total: < 1s for a typical Nova stack
|
|
||||||
|
|
||||||
The kyverno-json pass runs **in parallel** with Checkov (REQ-301 — the
|
|
||||||
script launches both and waits on both), so the wall-clock impact is
|
|
||||||
`max(checkov_time, kj_time)` ≈ checkov_time (kj is faster). The
|
|
||||||
contract + stack-IR policies run during resolve (already a fast step).
|
|
||||||
Meta-policies run over the merged list (in-memory, < 10ms). **No
|
|
||||||
measurable MTTR impact** is expected. This will be verified in P3
|
|
||||||
VERIFY with a timing assertion.
|
|
||||||
|
|
||||||
## 6. "Platform functions without AI" tenet (G-Q1 / D-120)
|
|
||||||
|
|
||||||
kyverno-json is deterministic (same policy + payload → same result,
|
|
||||||
every run). It is not an LLM, not a probabilistic model, not a
|
|
||||||
"judgement" engine. The NORTH_STAR.md tenet ("the platform functions
|
|
||||||
without AI — 'AI decisions' are really automated decisions")
|
|
||||||
distinguishes AI (non-reproducible) from automation (reproducible).
|
|
||||||
kyverno-json is the latter. Adding it is **more** aligned with the
|
|
||||||
tenet than the current imperative Python in `core/env_transition.py`
|
|
||||||
and `core/regression_verify.py`, because the policy is declarative
|
|
||||||
(visible, auditable, version-controlled) rather than imperative (logic
|
|
||||||
hidden in function bodies). The `is_configured()` guard ensures the
|
|
||||||
platform functions without the binary (graceful skip → `SKIPPED` PCR
|
|
||||||
→ confidence signal proceeds).
|
|
||||||
|
|
||||||
## 7. ECS policy catalog overlap (prior art)
|
|
||||||
|
|
||||||
The kyverno-json catalog ships ECS policies that overlap with Nova's
|
|
||||||
L1 modules: `ecs-cluster-enable-logging`, `ecs-cluster-required-
|
|
||||||
container-insights`, `ecs-service-public-ip`, `ecs-service-required-
|
|
||||||
latest-platform-fargate`, `ecs-task-definition-fs-read-only`. These are
|
|
||||||
**reference policies**, not drop-in Nova policies — they target the
|
|
||||||
AWS ECS API shape (`type: aws_ecs_service` etc.), not Nova's Stack IR
|
|
||||||
shape. v1.25 policies target the Nova IR (REQ-297) and the terraform
|
|
||||||
plan JSON (REQ-300), not the raw AWS API. The catalog is useful as
|
|
||||||
prior art for JMESPath patterns over ECS resources — the
|
|
||||||
`ecs-service-public-ip` policy's `contains('$allowed-values',
|
|
||||||
@.assign_public_ip)` pattern informs the Nova `forbid-public-ingress`
|
|
||||||
policy shape. No catalog policies are imported directly in v1.25.
|
|
||||||
|
|
||||||
## 8. Risks & mitigations
|
|
||||||
|
|
||||||
| Risk | Likelihood | Impact | Mitigation |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `kj` binary not in CI image | medium | blocks P3+ tests | `is_configured()` guard + `pytest.skip` + `scripts/install-kyverno-json.sh` |
|
|
||||||
| kyverno-json output shape changes across versions | low | breaks `_to_pcr()` | pin `@latest` to a known-good commit in `install-kyverno-json.sh` after P1 smoke; defensive parsing (malformed → `error` PCR, not exception) |
|
|
||||||
| Policy explosion (4 targets × N rules) | medium | maintenance load | wave ordering (PLAN); policies co-located per target dir; meta-policy cross-check keeps the set auditable |
|
|
||||||
| Checkov + kj tagging-rule drift | medium | false `error` PCRs | `tagging-rules-agree` meta-policy emits `error` on divergence (visible, not silent); the Checkov rule stays source of truth for HCL, kj for IR |
|
|
||||||
| OPA swap turns out harder than the protocol implies | low | future milestone rework | RESEARCH §4.2 documents the OPA-equivalent surface; the protocol is the contract, not the implementation |
|
|
||||||
| `--pre-process` needed for meta-policies but undocumented behavior | low | meta-policy bugs | v1.25 meta-policies use plain assertion trees over the PCR list (no pre-process); `--pre-process` noted as a future optimization only |
|
|
||||||
|
|
||||||
## 9. Assumptions (logged, full autonomy)
|
|
||||||
|
|
||||||
- A1: `kj scan --output json` produces a stable `results[]` array shape.
|
|
||||||
Will be verified in P1 smoke test (`_smoke.json` policy + a trivial
|
|
||||||
payload); if the shape differs, `_to_pcr()` is adjusted defensively
|
|
||||||
(malformed → `error` PCR). Confidence: 0.85.
|
|
||||||
- A2: The `nova.cloudinit.dev/severity` annotation convention is
|
|
||||||
read by the engine from the policy YAML (loaded once per evaluate()
|
|
||||||
call). kyverno-json does not validate unknown annotations — they pass
|
|
||||||
through. Confidence: 0.90.
|
|
||||||
- A3: The `~` projection modifier iterates `resources[]` in the Stack
|
|
||||||
IR and `planned_values.root_module.resources[]` in the plan JSON
|
|
||||||
correctly. Verified in P2/P3 tests. Confidence: 0.85.
|
|
||||||
- A4: `go install` works in the CI image (Go toolchain available or
|
|
||||||
installable). If not, the binary-release download path is the
|
|
||||||
documented fallback in `install-kyverno-json.sh`. Confidence: 0.80.
|
|
||||||
- A5: The `NullEngine` fallback (when `policy` key absent in
|
|
||||||
config.json) keeps all existing tests passing — they don't set the
|
|
||||||
key, so they get `NullEngine` → `SKIPPED` PCRs → confidence signal
|
|
||||||
proceeds with `policy` input `[SKIPPED]` → per-input score 1.0
|
|
||||||
(skipped counts as pass in `_per_input_score`). Confidence: 0.95
|
|
||||||
(verified against `confidence_signal.py:84-89`).
|
|
||||||
|
|
||||||
## 10. Decisions referenced
|
|
||||||
|
|
||||||
D-115 (install path), D-116 (engine enum reuse), D-117 (adapter
|
|
||||||
signatures unchanged), D-118 (tagging cross-check), D-119 (critical-
|
|
||||||
override defense-in-depth), D-120 (deterministic not AI). See
|
|
||||||
CLARIFY.md for the full resolution text.
|
|
||||||
|
|
||||||
## 11. Architecture updates (deferred to RESEARCH-stage file edits)
|
|
||||||
|
|
||||||
- `.ciagent/ARCHITECTURE.md` gains §12.7 "Policy Engine Registry" with
|
|
||||||
the registry diagram. Deferred to the RESEARCH commit (this file's
|
|
||||||
commit) — the section is authored as part of this research.
|
|
||||||
- `schemas/README.md` notes `engine: "kyverno"` is shared by the K8s
|
|
||||||
adapter and kyverno-json (distinguished by `ruleId` prefix).
|
|
||||||
- `modules/STANDARDS.md` gains a "Policy authoring standard" section
|
|
||||||
(P4, REQ-307).
|
|
||||||
- `docs/METRICS.md` notes the policy engine is swappable (P4, REQ-307).
|
|
||||||
+270
-2161
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
|||||||
|
# Nova — System State (what exists today)
|
||||||
|
|
||||||
|
> **PO-owned catalog of shipped capabilities.** Updated at every milestone
|
||||||
|
> ship (P final). Additive only — entries are appended, never rewritten,
|
||||||
|
> unless a capability is explicitly deprecated (then marked, not deleted).
|
||||||
|
> Read by the PO upstream of the PDLC before authoring new REQ-NNN specs,
|
||||||
|
> and by CIAgent at SPECIFY for capability awareness.
|
||||||
|
>
|
||||||
|
> **Authority:** this file is *descriptive of shipped state*, not
|
||||||
|
> authoritative for live phase/ship state — that's `CHECKPOINT.json`. For
|
||||||
|
> *why*, read `NORTH_STAR.md`. For *how*, read `ARCHITECTURE.md`. For
|
||||||
|
> *what was decided*, read `PROJECT.md` load-bearing decisions.
|
||||||
|
>
|
||||||
|
> **Last milestone ship:** v1.29 (`v1.28.6`, 2026-08-20) — Reposplit +
|
||||||
|
> Identity Layer Bring-Live. Feature milestone: platform operations
|
||||||
|
> extracted to a Gitea-private Terraform repo (`nova-platform-ops`,
|
||||||
|
> OPER-PRIV); `acdl/acdl` standardized on GitHub (D-232, `.gitea/`
|
||||||
|
> removed, `forge_parity_disabled` CI assertion); Nova-idp brought
|
||||||
|
> live in `581513795199` via Terraform (CFN archived to
|
||||||
|
> `docs/archive/nova-idp-cfn-v1.28.md`, REQ-369, `nova idp setup
|
||||||
|
> --apply` delegates to `terraform apply`); `kj` substrate has one ECR
|
||||||
|
> image digest shared by the Lambda runtime + its Fargate fallback
|
||||||
|
> (KJ-LOCKSTEP, REQ-371, D-238, `lifecycle.precondition` on both
|
||||||
|
> image-bearing resources); JWKS edge-only via CloudFront + OAC
|
||||||
|
> (INV-18, D-233); `publish.yml` tag-triggered with ECR image build
|
||||||
|
> (static `kj`, `CGO_ENABLED=0`, KJ-STATIC `file(1)` gate, REQ-354);
|
||||||
|
> operator guide `docs/operator-guide-platform-ops.md` (747 lines, 18
|
||||||
|
> sections + Cutover Gates table); consumer `nova-blockchain-exchange`
|
||||||
|
> deploy.yml bumped `@v1.25` → `@v1.29`. 6 acdl-side REQs complete + 14
|
||||||
|
> covered-reference REQs (355-366, 371, verified via M1/M1.5/M2 cutover
|
||||||
|
> gates, operator-attested). 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). Grill PROCEED 0.72 (4 critical fixes). Review
|
||||||
|
> PASS-WITH-ISSUES (3 P0 fixes). Audit all PASS. Merged to main
|
||||||
|
> `9dc5669`, pushed + 8 Gitea releases created (ids 803-810).
|
||||||
|
> **Next update:** at v1.30 ship.
|
||||||
|
|
||||||
|
## How to use this file (PO)
|
||||||
|
|
||||||
|
- Before writing a new REQ: search this file for the capability you
|
||||||
|
intend to spec. If it exists, extend it; do not re-spec it under a new
|
||||||
|
REQ-NNN.
|
||||||
|
- Respect the **Invariants** below — they are load-bearing and
|
||||||
|
cross-cutting. A new REQ that violates an invariant requires a
|
||||||
|
`CLARIFY` decision recorded in PROJECT.md.
|
||||||
|
- Anchor each new REQ to a **Domain**; new domains require a PO
|
||||||
|
decision recorded in CLARIFY.
|
||||||
|
- When a capability is deprecated (replaced, removed, or
|
||||||
|
re-architecture), append a `Deprecated` row marking the milestone +
|
||||||
|
replacement; do not delete the original entry.
|
||||||
|
|
||||||
|
## Invariants (PO-owned — do not violate in new REQs)
|
||||||
|
|
||||||
|
> Distilled from `PROJECT.md` load-bearing decisions D-034..D-072 +
|
||||||
|
> W1..BA + Q1.3. Cite the decision ID when an REQ touches one.
|
||||||
|
|
||||||
|
- **INV-1 (Contract surface):** The only PDLC→Nova boundary is
|
||||||
|
`schemas/contract.schema.json` + `schemas/submission-readiness.schema.json`
|
||||||
|
(D-133). All consumer intent enters through one of these. Nova never
|
||||||
|
reaches into upstream PDLC.
|
||||||
|
- **INV-2 (Confidence inputs):** Six canonical inputs — policy (0.30),
|
||||||
|
validation (0.25), freshness (0.10), source (0.15), history (0.10),
|
||||||
|
nfrs (0.10). Weights frozen for v1 (D-040). `critical` severity =
|
||||||
|
hard-block via `PENALTY["critical"]: None` (defense-in-depth behind the
|
||||||
|
declarative `block-on-any-critical` meta-policy).
|
||||||
|
- **INV-3 (HITL gates):** dev = autonomous (≥0.50); qa = HITL (≥0.75);
|
||||||
|
prod = HITL (≥0.90); dr = HITL (≥0.95). Approver identity = Gitea
|
||||||
|
`gitea.actor` of the `workflow_dispatch` (D-042). Separation-of-duties
|
||||||
|
on prod reads `approver_qa` from the DynamoDB outbox.
|
||||||
|
- **INV-4 (Engine is swappable):** The policy engine is behind the
|
||||||
|
`PolicyEngine` protocol (`core/policy_engine.py`, v1.25). Confidence
|
||||||
|
signal + pipeline import only the protocol, never a concrete engine.
|
||||||
|
`kyverno-json` is the v1.25 default; `OPA` (or other) implements the
|
||||||
|
same 3-method protocol to replace it.
|
||||||
|
- **INV-5 (Adapter is stateless):** `adapters/terraform/adapter.py` owns
|
||||||
|
no module content — no `TYPE_MAP`/`INPUT_MAP`/`OUTPUT_MAP` (v1.11
|
||||||
|
rewrite). A new stack type requires a new L1 module
|
||||||
|
(`modules/l1/<name>/`) + `registry.json` entry, not an adapter change.
|
||||||
|
- **INV-6 (Audit stream is immutable):** Outbox writes via SQLite
|
||||||
|
hash-chain today (D-083 deferred). S3 Object Lock / JWS tamper-
|
||||||
|
*resistant* ledger is a future milestone. Current stream is tamper-
|
||||||
|
*evident* (any tampering breaks the chain).
|
||||||
|
- **INV-7 (PCR schema is the moat):** `schemas/policy_check_result.schema.json`
|
||||||
|
shape is frozen across adapter swaps (v1.25 hard constraint). The
|
||||||
|
`engine` enum already includes `"kyverno"` + `"opa"`; new engines add
|
||||||
|
no enum value.
|
||||||
|
- **INV-8 (Long-lived creds forbidden):** §12.5. The D-039/D-047 per-run-
|
||||||
|
rotated-key waiver satisfies the *intent* (no *persistently* long-lived
|
||||||
|
key). Real OIDC federation is blocked on `go-gitea/gitea#36988`.
|
||||||
|
- **INV-9 (Two consumer surfaces, one platform):** L3A (developer) +
|
||||||
|
L3B (citizen dev) converge on the same contract schema, the same
|
||||||
|
policy envelope, and the same evidence stream.
|
||||||
|
- **INV-10 (Nova is downstream of PDLC):** Nova governs infra + delivery
|
||||||
|
only. Product backlog, code authorship, IDE workflows, application
|
||||||
|
business logic are upstream. Integration only via the validated
|
||||||
|
contract boundary (INV-1).
|
||||||
|
- **INV-11 (Pilot scope, v1.26):** Equities only (D-200). Single-
|
||||||
|
validator PoA (D-201). D-083 (Object Lock/JWS) stays deferred. Hot
|
||||||
|
path deferred (D-126). Multi-cloud deferred. Multi-validator BFT
|
||||||
|
deferred. The pilot runs `mode: full` for `dev` only (D-209); qa/prod/dr
|
||||||
|
stay placeholder (D-208, blocked by the pilot-readiness policy).
|
||||||
|
- **INV-12 (Mode observability, v1.28):** Every CLI invocation emits a
|
||||||
|
`cli.invocation` audit event containing `mode`, `selection_reason`,
|
||||||
|
`credential_type`, `command`, and `args`. Operators can debug mode
|
||||||
|
selection without reproducing.
|
||||||
|
- **INV-13 (Mode resolution determinism, v1.28):** Resolution priority
|
||||||
|
is flag → env (`NOVA_CLIENT_MODE`) → credential type →
|
||||||
|
`sys.stdin.isatty()`. No silent fallbacks. Invalid env values are
|
||||||
|
ignored + warned. Deviations rejected at PR time.
|
||||||
|
- **INV-14 (Credential type encodes role, v1.28):** `developer_pat` /
|
||||||
|
`nova_oidc_token` + TTY present → `interactive`; TTY absent → `agent`.
|
||||||
|
- **INV-15 (No AWS-managed identity in path, v1.28):** Nova-idp MUST
|
||||||
|
NOT depend on Cognito, IAM Identity Center, or any AWS-managed
|
||||||
|
identity service. Greenfield constraint (no Cognito existed to
|
||||||
|
"drop").
|
||||||
|
- **INV-16 (Password storage, v1.28):** Passwords hashed with Argon2id
|
||||||
|
(t=3, m=65536 KiB, p=1). Fail-closed on `ImportError` (D-228 amended
|
||||||
|
— no pure-Python fallback). Raw passwords never in logs/traces/env/
|
||||||
|
DynamoDB.
|
||||||
|
- **INV-17 (ABAC discipline, v1.28):** The token-vend Lambda evaluates
|
||||||
|
the kyverno-json ABAC policy before signing. Fail-closed on `kj`
|
||||||
|
absence or evaluation error (C-6.1 — never fail open). Allow/deny +
|
||||||
|
policy inputs emitted to the audit stream. `policy_version` (git SHA,
|
||||||
|
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)
|
||||||
|
|
||||||
|
1. Contract surface
|
||||||
|
2. Modules (L1 primitives + L2 patterns)
|
||||||
|
3. Policy engine
|
||||||
|
4. Confidence signal
|
||||||
|
5. Environments & promotion
|
||||||
|
6. Evidence stream & audit
|
||||||
|
7. Telemetry & metrics
|
||||||
|
8. Consumer surfaces (developer + agentic)
|
||||||
|
9. Pilot estate (v1.26)
|
||||||
|
10. Forge / CI runtime
|
||||||
|
11. CLI + Identity Layer (v1.28)
|
||||||
|
12. Platform Ops Reposplit (v1.29)
|
||||||
|
|
||||||
|
## Capabilities (additive — one row per shipped capability)
|
||||||
|
|
||||||
|
> Tier: **local** = runs via emulating adapters (no AWS); **live-aws** =
|
||||||
|
> runs against the live AWS account `581513795199`;
|
||||||
|
> **lifecycle-pipeline** = verified via the `modules-lifecycle`
|
||||||
|
> pipeline's apply→modify→destroy matrix cell.
|
||||||
|
> CAP-NNN IDs cross-reference the regression gate at
|
||||||
|
> `core/regression_verify.py` (the machine registry). This file is the
|
||||||
|
> PO-facing narrative; the machine registry is the source of truth for
|
||||||
|
> the gate.
|
||||||
|
|
||||||
|
### Domain 1 — Contract surface
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-001 | `contract.schema.json` validates sample contracts | v1.1 / `v1.2.0` | `schemas/contract.schema.json` | REQ-001, D-... | local | shape: id/name/environment/infrastructure |
|
||||||
|
| CAP-002 | `environment.schema.json` validates env files | v1.9 / `v1.9.0` | `schemas/environment.schema.json` | REQ-040 | local | dev/qa/prod/dr env JSONs |
|
||||||
|
| CAP-006 | Contract interpolation expands `${env.*}` / `${contract.*}` | v1.9 / `v1.9.0` | `core/contract_resolver.py` | REQ-040 | local | per-env variants |
|
||||||
|
| — | Submission-readiness gate (superset of contract schema) | v1.18 / `v1.18.0` | `schemas/submission-readiness.schema.json`, `core/submission_readiness.py` | REQ-217, REQ-218, D-133 | local | the only PDLC→Nova boundary (INV-1) |
|
||||||
|
|
||||||
|
### Domain 2 — Modules (L1 primitives + L2 patterns)
|
||||||
|
|
||||||
|
> Source: `modules/registry.json` (the authoritative module catalog).
|
||||||
|
> STATE.md lists the *capability* of having a registered module;
|
||||||
|
> registry.json is the live registry.
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-003 | contract_resolver resolves `static-assets` (L2) | v1.1 / `v1.2.0` | `core/contract_resolver.py`, `modules/l2/static-assets/` | REQ-003 | local | CloudFront+WAF+S3 pattern |
|
||||||
|
| CAP-004 | contract_resolver resolves `microservice` (L2) | v1.2 / `v1.3.0` | `core/contract_resolver.py`, `modules/l2/microservice/` | REQ-004 | local | ECS Fargate pattern (6 L1 children) |
|
||||||
|
| CAP-005 | Terraform adapter compiles resolved stack to `.tf` | v1.1 / `v1.2.0` | `adapters/terraform/adapter.py` | REQ-005 | local | stateless assembler (v1.11); emits `module "<rid>" { source }` blocks |
|
||||||
|
| — | L1 `s3` primitive | v1.1 / `v1.2.0` | `modules/l1/s3/` | REQ-005 | lifecycle | versioning + SSE-KMS by default |
|
||||||
|
| — | L1 `vpc` primitive | v1.1 / `v1.2.0` | `modules/l1/vpc/` | REQ-005 | lifecycle | shared platform VPC (v1.11) |
|
||||||
|
| — | L1 `ecs-cluster` primitive | v1.1 / `v1.2.0` | `modules/l1/ecs-cluster/` | REQ-005 | lifecycle | |
|
||||||
|
| — | L1 `ecs-service` primitive | v1.1 / `v1.2.0` | `modules/l1/ecs-service/` | REQ-005 | lifecycle | execution_role_arn + task_role_arn wired (P4 W1 fix, v1.26) |
|
||||||
|
| — | L1 `iam-role` primitive | v1.1 / `v1.2.0` | `modules/l1/iam-role/` | REQ-005 | lifecycle | |
|
||||||
|
| — | L1 `alb` primitive | v1.1 / `v1.2.0` | `modules/l1/alb/` | REQ-005 | lifecycle | requires SG wire (P4 W1 fix, v1.26) |
|
||||||
|
| — | L1 `ecr` primitive | v1.1 / `v1.2.0` | `modules/l1/ecr/` | REQ-005 | lifecycle | |
|
||||||
|
| — | L1 `cloudfront` primitive | v1.7 / `v1.7.0` | `modules/l1/cloudfront/` | REQ-049, D-049 | lifecycle | OAC + WAF (production edge) |
|
||||||
|
| — | L1 `waf` primitive | v1.7 / `v1.7.0` | `modules/l1/waf/` | REQ-049, D-049 | lifecycle | |
|
||||||
|
| — | L1 `rds` primitive | v1.7 / `v1.7.0` | `modules/l1/rds/` | REQ-059, D-059 | lifecycle | multi-engine input (postgres/mysql/...) |
|
||||||
|
| — | L1 `kms-key` primitive | v1.8 / `v1.8.0` | `modules/l1/kms-key/` | REQ-069, D-069 | lifecycle | per-stack CMK; 90-day rotation |
|
||||||
|
| — | L1 `uptime` primitive | v1.8 / `v1.8.0` | `modules/l1/uptime/` | REQ-066, D-066 | lifecycle | uptime-kuma on ECS Fargate |
|
||||||
|
| — | L1 `dynamodb` primitive | v1.26 / `v1.25.2` | `modules/l1/dynamodb/` | REQ-322 | local | PK + optional SK; PAY_PER_REQUEST; encryption + PITR by default (v1.8 NFRs) |
|
||||||
|
| CAP-013 | `terraform init+validate+plan` live AWS (microservice) | v1.2 / `v1.3.0` | `adapters/terraform/adapter.py` | REQ-013 | live-aws | 14 resources; plan saved |
|
||||||
|
| CAP-014 | `terraform init+validate+plan` live AWS (static-assets) | v1.7 / `v1.7.0` | `adapters/terraform/adapter.py` | REQ-014 | live-aws | CloudFront+WAF+S3 plan OK |
|
||||||
|
| CAP-017 | DynamoDB `nova-contracts` table | v1.7 / `v1.7.0` | `core/lambda/`, `terraform/` | REQ-068, D-068 | lifecycle | PK `changeRequestId`, SK `submittedAt` (CMDB) |
|
||||||
|
| CAP-018 | Lambda contract-ingestor | v1.7 / `v1.7.0` | `core/lambda/contract_ingestor.py` | REQ-051, D-051 | lifecycle | local stub + lifecycle evidence |
|
||||||
|
| CAP-019 | ECS cluster + service (L2 microservice) | v1.7 / `v1.7.0` | `modules/l2/microservice/` | REQ-066 | lifecycle | apply/modify/destroy exit 0 |
|
||||||
|
| CAP-020 | CloudFront + WAF production stack | v1.7 / `v1.7.0` | `modules/l2/static-assets/` | REQ-049 | lifecycle | apply/modify/destroy exit 0 |
|
||||||
|
| CAP-021 | uptime-kuma monitoring primitive | v1.8 / `v1.8.0` | `modules/l1/uptime/` | REQ-066 | lifecycle | |
|
||||||
|
| CAP-022 | OIDC role for act_runner | v1.11 / `v1.11.0` | `terraform/bootstrap/` | REQ-116, D-039 | lifecycle | real OIDC blocked on go-gitea/gitea#36988 |
|
||||||
|
|
||||||
|
### Domain 3 — Policy engine
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| — | `PolicyEngine` Protocol + `PolicyEngineRegistry` | v1.25 / `v1.24.1` | `core/policy_engine.py` | REQ-291, REQ-292 | local | selects engine from `config.json.policy.engine`; `NullEngine` fallback when key absent |
|
||||||
|
| — | `KyvernoJsonEngine` adapter (shells to `kj scan`) | v1.25 / `v1.24.1` | `adapters/kyverno-json/kyverno_json_engine.py` | REQ-293, REQ-294 | local | `is_configured()` guards on `which kj`; `SKIPPED` PCR when absent |
|
||||||
|
| — | Contract policies (4) over consumer contract JSON | v1.25 / `v1.24.2` | `adapters/kyverno-json/policies/contract/` | REQ-295, REQ-296 | local | id-pattern, env-enum, infra-min-1, forbid-unknown-fields |
|
||||||
|
| — | Stack-IR policies (3) over resolved Target Stack IR | v1.25 / `v1.24.2` | `adapters/kyverno-json/policies/stack-ir/` | REQ-297, REQ-298, REQ-299 | local | tagging-standard, public-ingress, encryption-by-default |
|
||||||
|
| — | Plan-JSON policies (3) over `terraform show -json` | v1.25 / `v1.24.3` | `adapters/kyverno-json/policies/plan-json/` | REQ-300, REQ-301, REQ-302 | local | plaintext-secrets, iam-wildcard, kms-reference |
|
||||||
|
| — | Meta-policies over merged PCR list | v1.25 / `v1.24.3` | `adapters/kyverno-json/policies/meta/` | REQ-303 | local | `block-on-any-critical` (declarative critical-block); `tagging-rules-agree` (Checkov↔kj agree) |
|
||||||
|
| — | Regression-gate policies (3) over capability-inventory JSON | v1.25 / `v1.24.4` | `adapters/kyverno-json/policies/regression/` | REQ-304, REQ-305 | local | declarative mirrors of CAP-013/023/024 imperative checks |
|
||||||
|
| — | Pilot-readiness policy (no placeholder account) | v1.26 / `v1.25.3` | `adapters/kyverno-json/policies/pilot-readiness/no-placeholder-account.json` | REQ-320 | local | fail-closed gate; blocks apply on `account_id == "000000000000"` |
|
||||||
|
| — | Settlement-finality policy | v1.26 / `v1.25.3` | `adapters/kyverno-json/policies/settlement-finality/all-matches-committed.json` | REQ-315 | local | authored + tested; enforcement deferred to milestone that binds qa/prod/dr (D-208) |
|
||||||
|
| — | Checkov adapter (raw-finding source) | v1.7 / `v1.7.0` | `adapters/terraform/checkov_adapter.py` | REQ-053 | local | feeds meta-policies; `NOVA_TAG_NAMING` custom rule is the TF-static source of truth |
|
||||||
|
| — | Wiz adapter (raw-finding source) | v1.7 / `v1.7.0` | `adapters/wiz/` | REQ-053 | local | API findings; `is_configured()` guard |
|
||||||
|
| — | K8s Kyverno adapter (documentation-only) | v1.7 / `v1.7.0` | `adapters/kyverno/` | REQ-053, D-053 | local | inactive for Terraform-only stacks; activates when GitOps emits K8s manifests |
|
||||||
|
|
||||||
|
### Domain 4 — Confidence signal
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-007 | `confidence_signal.compute` returns a band | v1.1 / `v1.2.0` | `core/confidence_signal.py` | REQ-007, D-040 | local | 6 inputs (INV-2); band ∈ {pass, block} |
|
||||||
|
| — | `escalation_reason: 'confidence'` on `band == 'block'` | v1.26 / `v1.25.3` | `core/confidence_signal.py` | REQ-318 | local | grounds Human Escalation Frequency numerator |
|
||||||
|
|
||||||
|
### Domain 5 — Environments & promotion
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| — | env-JSON `state_backend` wiring | v1.26 / `v1.25.3` | `core/environments/*.json`, `adapters/terraform/adapter.py` | REQ-319, D-... | local | adapter reads `env.state_backend.bucket` (fallback to computed name) |
|
||||||
|
| — | Environment progression (dev autonomous → qa/prod/dr HITL) | v1.1 / `v1.2.0` | `core/env_transition.py`, `core/hitl_gates.py` | REQ-042, D-042 | local | destroy-on-environment-change (v1.24) |
|
||||||
|
| — | Decommission mode (2-step, HITL SRE gates) | v1.8 / `v1.8.0` | `core/env_transition.py`, `scripts/run_platform.sh` | REQ-070, D-070 | local | `mode: decommission` requires `changeRequestId` |
|
||||||
|
| — | Per-env mandatory metadata (W3.E) | v1.1 / `v1.2.0` | `schemas/submission-readiness.schema.json` | W3.E | local | dev=stack+env; qa+=e2e+load; prod+=runbook+dashboard+oncall; dr+=drDrillRef |
|
||||||
|
|
||||||
|
### Domain 6 — Evidence stream & audit
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-008 | outbox_writer builds a hash-chained item | v1.1 / `v1.2.0` | `core/outbox_writer.py` | REQ-008 | local | tamper-evident (INV-6); tamper-resistant deferred (D-083) |
|
||||||
|
| CAP-015 | DynamoDB outbox table exists + describable | v1.1 / `v1.2.0` | `core/outbox_writer.py` | REQ-015 | live-aws | `nova-outbox` (post-v1.26 re-bootstrap) |
|
||||||
|
| CAP-016 | S3 state bucket exists + readable | v1.1 / `v1.2.0` | `terraform/bootstrap/` | REQ-016 | live-aws | `nova-tfstate-581513795199-us-east-1` |
|
||||||
|
| — | Decision Ledger (SQLite hash-chain) | v1.17 / `v1.17.0` | `core/metrics/decision_ledger.py` | REQ-185, REQ-186 | local | cold store for metrics; `ai.decision.made` + `attestation.recorded` events |
|
||||||
|
| — | SSM Parameter Store deploy outputs (SecureString, KMS) | v1.7 / `v1.7.0` | `core/output_publisher.py` | REQ-050, D-050 | live-aws | `/acdl/{env}/{contractId}/{output_name}` |
|
||||||
|
| — | GitHub PR comment / job summary deploy outputs | v1.7 / `v1.7.0` | `scripts/run_platform.sh` | REQ-050, D-050 | local | no raw secrets in logs |
|
||||||
|
| — | Uniform error reporting via Lambda `report_error` | v1.7 / `v1.7.0` | `core/lambda/contract_ingestor.py` | REQ-055, D-055 | live-aws | GitHub issue on platform repo `acdl/acdl`; idempotent |
|
||||||
|
| — | Tagging standard enforcement (4 required tags) | v1.7 / `v1.7.0` | `schemas/tagging-standard.json`, `adapters/terraform/policy/custom_rules/nova_tagging.py` | REQ-054, D-054 | local | `nova:owner`, `nova:contract`, `nova:environment`, `nova:cost-center` |
|
||||||
|
| — | Encryption + deletion-protection by default | v1.8 / `v1.8.0` | `modules/l1/*/terraform/main.tf` | REQ-062, REQ-069, D-062, D-069, D-072 | local | per-stack CMK; managed KMS fallback for standalone L1 (D-072) |
|
||||||
|
|
||||||
|
### Domain 7 — Telemetry & metrics
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-023 | metrics collector runs + emits expected schema | v1.17 / `v1.17.0` | `core/metrics/collector.py` | REQ-194 | local | fact_run, fact_decision, fact_attestation dims |
|
||||||
|
| CAP-024 | unified deck structure (slide count, x3 arc, per-slide benefits) | v1.17 / `v1.17.0` | `docs/presentations/nova-autonomous-cloud-delivery-marp.md` | REQ-194 | local | single source-of-truth marp deck |
|
||||||
|
| — | Outcome backfill (`pending` → `succeeded`/`failed`) | v1.26 / `v1.25.3` | `core/metrics/outcome_backfill.py` | REQ-317 | local | idempotent + terminal; grounds AI Decision Accuracy |
|
||||||
|
| — | Trust Snapshot | v1.17 / `v1.17.0` | `metrics/TRUST_SNAPSHOT.md`, `core/metrics/trust_snapshot.py` | REQ-194 | local | leadership-ready trust verdict |
|
||||||
|
| — | PowerBI export (fact/dimension views + 8 placeholder views) | v1.17 / `v1.17.0` | `metrics/powerbi/` | REQ-194 | local | deferred metrics ship as documented-schema placeholders |
|
||||||
|
| — | Pre-apply Infracost estimate | v1.17 / `v1.17.0` | `scripts/run_platform.sh` | REQ-119 | local | `nova.cost.estimated`; actual-spend CUR reconciliation deferred (D-096) |
|
||||||
|
| — | Regression gate (`scripts/run_regression.sh`) | v1.10 / `v1.10.0` | `core/regression_verify.py`, `scripts/run_regression.sh` | REQ-090, REQ-121 | local | fails closed on any non-Verified CAP; CAP-001..025 |
|
||||||
|
|
||||||
|
### Domain 8 — Consumer surfaces (developer + agentic)
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| — | Reusable deploy workflow (`deploy.yml@v1.25`) | v1.5 / `v1.5.0` | `.github/workflows/deploy.yml`, `.gitea/workflows/deploy.yml` | REQ-105 | local | `workflow_call`; modes: full/plan-only/check-only/decommission |
|
||||||
|
| — | Consumer onboarding (developer + citizen-dev paths) | v1.1 / `v1.2.0` | `docs/ONBOARDING.md`, `docs/consumer-guide.md` | BA.E, W3.E | local | both end in a sandbox dev submission that must pass the confidence gate |
|
||||||
|
| — | Atelier MCP server (agentic validation) | v1.18 / `v1.18.0` | `mcp/atelier/server.py` | REQ-221, REQ-222 | local | `atelier.validate_against_principles` tool |
|
||||||
|
| — | 9 production-grade engineering skills | v1.18 / `v1.18.0` | `skills/{api,security,data,testing,observability,errors,devops,infrastructure-as-code,compliance}.md` | REQ-221, REQ-222, BA.A | local | indexed by `docs/skills.md`; review/agent-checklist.md gate |
|
||||||
|
| — | Module examples (validated against contract schema) | v1.7 / `v1.7.0` | `modules/<name>/examples/{simple,complex}.yml` | REQ-058, D-058 | local | examples cannot drift from schema silently |
|
||||||
|
|
||||||
|
### Domain 9 — Pilot estate (v1.26)
|
||||||
|
|
||||||
|
> The first real consumer estate. `nova-blockchain-exchange` repo
|
||||||
|
> (Gitea `continuous-intelligence/nova-blockchain-exchange`, local clone
|
||||||
|
> `/root/nova-blockchain-exchange`). Homegrown PoA blockchain, equities
|
||||||
|
> only, single validator, T+1 settlement finality = block commit.
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-026 | PoA blockchain core (block + ledger + validator) | v1.26 / `v1.25.1` | `chain/block.py`, `chain/ledger.py`, `chain/validator.py` | REQ-310, D-201 | local | single validator; SHA-256 hash chain; deterministic block production |
|
||||||
|
| CAP-027 | Order-matching engine (limit order book) | v1.26 / `v1.25.1` | `engine/order_book.py`, `engine/order.py` | REQ-311 | local | price-time priority; partial fills |
|
||||||
|
| CAP-028 | T+1 settlement service | v1.26 / `v1.25.1` | `settlement/service.py` | REQ-312 | local | idempotent; finality = block commit |
|
||||||
|
| CAP-029 | Consumer `contract.yaml` (blockchain exchange) | v1.26 / `v1.25.2` | `nova-blockchain-exchange/contract.yaml`, `contracts/*.yml` | REQ-313 | local | per-env variants (dev/qa/prod); validated against contract schema |
|
||||||
|
| CAP-030 | Consumer deploy via `deploy.yml@v1.25` (inline adapter) | v1.26 / `v1.25.2` | `nova-blockchain-exchange/.github/workflows/deploy.yml`, `.gitea/workflows/deploy.yml` | REQ-314 | local | no cross-repo `uses:` (SPEC §10 Q1); checkout `acdl/acdl @ v1.25` into `platform/`, run `run_platform.sh` |
|
||||||
|
| CAP-025 | Live-pilot-apply regression capability (round-trip) | v1.26 / `v1.25.3` | `core/regression_verify.py` | REQ-316 | local | contract→adapter→plan→policy→confidence→attestation→outbox round-trip assertion |
|
||||||
|
| CAP-031 | Live pilot apply evidence (`blkex-pilot-apply-v0.2`) | v1.26 / `v1.25.4` | `.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` | REQ-316, REQ-321 | live-aws | confidence 0.800 pass; outcome backfilled; hash chain valid; live apply against `581513795199` |
|
||||||
|
| CAP-032 | AWS key rotation scheduled workflow | v1.26 / `v1.25.3` | `workflows-src/rotate-aws-key.yml` | SPEC §5.9 | local | daily rotation; forge-agnostic token name (REQ-230) |
|
||||||
|
|
||||||
|
### Domain 10 — Forge / CI runtime
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-009 | offline pytest suite passes | v1.1 / `v1.2.0` | `tests/` | REQ-009 | local | 844 tests (v1.26 baseline) |
|
||||||
|
| CAP-010 | `run_ci.sh` reproduces CI pipeline locally | v1.4 / `v1.4.0` | `scripts/run_ci.sh` | REQ-010 | local | offline; contract→resolver→stack→adapter→structure validated |
|
||||||
|
| CAP-011 | headline E2E — local tier (microservice) | v1.2 / `v1.3.0` | `scripts/run_local_e2e.sh` | REQ-011, D-092 | local | emulating adapters (no AWS) |
|
||||||
|
| CAP-012 | local E2E — static-assets (no ECS) | v1.1 / `v1.2.0` | `scripts/run_local_e2e.sh` | REQ-012 | local | |
|
||||||
|
| — | `platform-test.yml` CI workflow | v1.4 / `v1.4.0` | `.github/workflows/platform-test.yml` | REQ-010 | local | platform repo only (consumer CI is per-consumer) |
|
||||||
|
| — | `modules-lifecycle` pipeline (apply→modify→destroy matrix) | v1.11 / `v1.11.0` | `.github/workflows/modules-lifecycle.yml` | REQ-121, D-096 | live-aws | per-module lifecycle cell; `ci-vpc-destroy` always runs |
|
||||||
|
| — | `release.yml` (semver + floating tag maintenance) | v1.7 / `v1.7.0` | `.github/workflows/release.yml` | REQ-... | local | `v1.25` + `v1` floating tags force-moved on merge to main |
|
||||||
|
| — | IAM policy baseline (`acdl-spike-runner-policy`) | v1.11 / `v1.11.0` | `terraform/bootstrap/spike_runner_policy.json`, `.ciagent/IAM_POLICY.md` | REQ-116, D-095 | live-aws | regression-tested by `tests/test_iam_policy_baseline.py`; OIDC role `acdl-act-runner-role` (CAP-022) |
|
||||||
|
| — | Local emulating adapters (no AWS) | v1.10 / `v1.10.0` | `core/local_lambda_stub.py`, `scripts/run_local_e2e.sh` | D-092 | local | proves runtime behavior without live AWS |
|
||||||
|
|
||||||
|
### Domain 11 — CLI + Identity Layer (v1.28)
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-033 | CLI subcommand surface exists | v1.28 / `v1.27.1` | `nova/cli.py`, `nova/<module>.py` (15 subcommands) | REQ-324 | local | `nova --help` lists a subcommand for every `core/` module; argparse-only, auto-discovered |
|
||||||
|
| CAP-034 | Subcommand delegates to `core/` | v1.28 / `v1.27.1` | `nova/<module>.py` | REQ-324 | local | ≤50 lines, ≤3 FunctionDef, all calls resolve to `core.*` imports; AST-scanned in `tests/test_cli_subcommands.py` |
|
||||||
|
| CAP-035 | Layer matches wheel | v1.28 / `v1.27.1` | `.github/workflows/publish.yml`, `.gitea/workflows/publish.yml`, SSM `/nova/layer/nova-cli/version` | REQ-323 | local | wheel + Lambda layer co-published with identical version; SSM mapping; CodeArtifact + fallback |
|
||||||
|
| CAP-036 | Nova-idp auth flow works | v1.28 / `v1.27.3` | `core/lambda/nova_idp_auth.py`, `tests/test_idp_auth.py` | REQ-333 | local | sign-up → sign-in → session E2E; Argon2id t=3 m=65536 p=1; fail-closed D-228; moto locally, real DDB in CI |
|
||||||
|
| CAP-037 | Token-vend signs via KMS | v1.28 / `v1.27.4` | `core/lambda/nova_idp_token_vend.py`, `core/kms_signing.py`, `tests/test_kms_roundtrip.py` | REQ-337 | local | ECDSA P-256 / ES256; DER→raw conversion; KMS round-trip test; mock KMS locally, real KMS in CI |
|
||||||
|
| CAP-038 | PAT issuance + revocation | v1.28 / `v1.27.4` | `core/pat_lifecycle.py`, `tests/test_pat_revocation.py` | REQ-342 | local | issue → vend → revoke → 403 within 60s P95; strong-read DDB (D-229); verified <1s locally |
|
||||||
|
| — | `nova init` scaffolds `.nova/` | v1.28 / `v1.27.1` | `nova/init.py`, `core/init_scaffold.py` | REQ-325 | local | `.nova/`, `.nova/contract.yml.attestations/`, `.gitignore` (secrets excluded) |
|
||||||
|
| — | `nova cli-action` composite action | v1.28 / `v1.27.1` | `.github/actions/nova-cli/action.yml` | REQ-326 | local | byte-identical GitHub + Gitea; Python 3.12 pinned; NFR-11 |
|
||||||
|
| — | `mode_resolver` (flag→env→cred→TTY) | v1.28 / `v1.27.1` | `core/mode_resolver.py` | REQ-327, D-226 | local | `sys.stdin.isatty()` (not stdout); hypothesis property tests |
|
||||||
|
| — | Dual-use Lambda/CLI import | v1.28 / `v1.27.2` | `core/lambda/contract_ingestor.py` | REQ-329 | local | shared `dispatch_action()`; ≥80% code share; NFR-7 |
|
||||||
|
| — | Local env synthesizer | v1.28 / `v1.27.2` | `core/env.py` (`synthesize_local_env`) | REQ-330 | local | `nova apply --local`; no cloud provisioning |
|
||||||
|
| — | JWS-from-PAT (HKDF-SHA256, HS256) | v1.28 / `v1.27.2` | `core/jws_attestation.py` | REQ-332, C-5.2 | local | symmetric; verification key derived from PAT via same KDF |
|
||||||
|
| — | JWKS endpoint (function URL) | v1.28 / `v1.27.4` | `core/lambda/nova_idp_jwks.py` | REQ-338, D-230 | local | `AuthType: NONE`; `Cache-Control: max-age=3600`; optional CloudFront/WAF |
|
||||||
|
| — | kyverno-json ABAC token-vend policy | v1.28 / `v1.27.4` | `platform/abac/token-vend.policy`, `core/abac_evaluator.py` | REQ-339, D-227 | local | fail-closed (C-6.1, 7 tests); `policy_version` git SHA (D-231) |
|
||||||
|
| — | `nova idp setup --check/--apply/--verify` | v1.28 / `v1.27.4` | `nova/idp/setup.py`, `core/lambda/nova_idp_setup.py`, `core/lambda/nova_idp_cfn.py` | REQ-340, REQ-341 | local | CloudFormation template review (NFR-10); IAM policy delta; KMS round-trip verify |
|
||||||
|
| — | `nova auth login/revoke/status` | v1.28 / `v1.27.4` | `nova/auth/{login,revoke,status}.py`, `core/auth_store.py` | REQ-344, C-7.3 | local | `~/.nova/credentials.json` 0600 stores OIDC token + metadata (NOT raw PAT) |
|
||||||
|
| — | E2E integration test | v1.28 / `v1.27.5` | `tests/test_e2e_idp.py` | REQ-348 | local | sign-up → sign-in → token-vend → apply → audit chain |
|
||||||
|
| — | Identity-layer threat model | v1.28 / `v1.27.5` | `docs/threat-model.md` | REQ-347 | local | 8 threats + C-9.2 INV-18..21 compression audit |
|
||||||
|
| — | 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 |
|
||||||
|
|
||||||
|
### Domain 12 — Platform Ops Reposplit (v1.29)
|
||||||
|
|
||||||
|
> Shipped at `v1.28.6` (2026-08-20). 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). The live cutover is an operator action — the
|
||||||
|
> acdl-side deliverables (publish.yml, operator guide, CFN archive,
|
||||||
|
> consumer bump) are complete.
|
||||||
|
|
||||||
|
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||||
|
|----|-----------|---------|-------|-------------|------|-------|
|
||||||
|
| CAP-039 | Platform ops reposplit | v1.29 / `v1.28.6` | `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` | `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` | `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
|
||||||
|
|
||||||
|
- **v1.0–v1.24 capability narrative + the 2026-07-27 re-verification sweep:**
|
||||||
|
`.ciagent/archive/CAPABILITY_INVENTORY-v1.10.md` (moved from
|
||||||
|
`.ciagent/CAPABILITY_INVENTORY.md` at v1.27). CAP-NNN IDs in this file
|
||||||
|
cross-reference the regression gate at `core/regression_verify.py`.
|
||||||
|
- **v1.0–v1.24 milestone narrative:** `.ciagent/archive/PROJECT-v1.0-v1.24.md`.
|
||||||
|
- **v1.0–v1.24 requirements (REQ-01..REQ-290):** `.ciagent/archive/REQUIREMENTS-v1.0-v1.24.md`.
|
||||||
|
- **v1.0–v1.24 phase breakdowns:** `.ciagent/archive/ROADMAP-v1.0-v1.24.md`.
|
||||||
|
- **v1.0–v1.24 architecture history:** `.ciagent/archive/ARCHITECTURE-v1.0-v1.24.md`.
|
||||||
|
- **v1.26 pre-execution artifacts (CLARIFY, GRILL, IDEATE, RESEARCH):**
|
||||||
|
`.ciagent/archive/{CLARIFY,GRILL,IDEATE,RESEARCH}-v1.26.md` (decisions
|
||||||
|
D-200..D-213 folded into `PROJECT.md` load-bearing decisions + PLAN.md
|
||||||
|
binding revisions at v1.27 archive time).
|
||||||
|
- **v1.26 phase verifications:** `.ciagent/archive/{VERIFY-P03,VERIFY-P04,REVIEW-AUDIT-P05}.md`.
|
||||||
|
- **v1.26 live pilot run evidence:** `.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md`.
|
||||||
|
- **v1.21 autonomy thesis (folded into NORTH_STAR.md Vision):** `.ciagent/archive/AUTONOMY_THESIS-v1.21.md`.
|
||||||
|
- **v1.14 AWS cost report (predates v1.26 live pilot):** `.ciagent/archive/COST-v1.14.md`.
|
||||||
|
|
||||||
|
## Update discipline
|
||||||
|
|
||||||
|
This file is updated **once per milestone, at the P-final milestone-ship
|
||||||
|
wave** (Wave 3 "milestone ship" in `PLAN.md`), alongside
|
||||||
|
`ROADMAP.md`/`NORTH_STAR.md`/`REQUIREMENTS.md`:
|
||||||
|
|
||||||
|
1. Append new capability entries for each shipped REQ (one row per
|
||||||
|
capability; group by domain).
|
||||||
|
2. Mark any deprecated capability with a `Deprecated` row citing the
|
||||||
|
milestone + replacement.
|
||||||
|
3. Bump the "Last milestone ship" header.
|
||||||
|
4. Do not rewrite existing entries (additive only).
|
||||||
|
|
||||||
|
Enforcement: convention (the P-final ship step names this file). A
|
||||||
|
drift-check gate (assert every REQ marked `complete` in
|
||||||
|
`REQUIREMENTS.md` traceability appears in STATE.md) is a future option
|
||||||
|
if the convention drifts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDLC Phase 0 Intake (current ground truth — 2026-08-20)
|
||||||
|
|
||||||
|
> Single-pass discovery for the next PDLC cycle. Populated from the
|
||||||
|
> live repo state after v1.29 ship. No aspirational items — state is
|
||||||
|
> what is, not what should be. Unknowns are explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Header (mandatory)
|
||||||
|
|
||||||
|
Project: Nova — The New Dawn of DevSecOps
|
||||||
|
|
||||||
|
Initiative: Leadership Presentation Deck — compressed (≤7 slides, S&P theme, 18-month CDLC→SDLC→PDLC roadmap)
|
||||||
|
|
||||||
|
Initiator: Product Owner / Manager (PDLC Phase 0 trigger)
|
||||||
|
|
||||||
|
Date (UTC): 2026-08-20
|
||||||
|
|
||||||
|
Current Version: v1.29 complete (tag `v1.28.6`, merged to main + pushed + released 2026-08-20); all 7 phases shipped; no phase in progress
|
||||||
|
|
||||||
|
System Health: YELLOW — coverage 73.8% below 80% release-gate floor (NFR debt carried from v1.28, unchanged through v1.29 feature milestone); nova-platform-ops M1 cutover pending operator action (covered-reference REQs 355-366, 371 not yet live-verified)
|
||||||
|
|
||||||
|
Raw Idea (≤ 3 sentences):
|
||||||
|
|
||||||
|
Technology Leadership needs a compressed presentation deck (≤7 slides, S&P theme colors) communicating: the problem statement, who the target audience is, what the platform is + how it solves the problem, what works now, and an 18-month roadmap from CDLC to SDLC + PDLC integration. Leaders do not want long presentations — the existing 23-slide deck is too verbose for this audience.
|
||||||
|
|
||||||
|
Trigger: post-v1.29 milestone completion — the platform has shipped reposplit + identity layer bring-live + the operator guide, making the story ready for leadership consumption.
|
||||||
|
|
||||||
|
Desired outcome: a leadership-ready deck (≤7 slides, Marp + python-pptx, S&P theme `#D6002A` / `#1B1B1B` / `#FFFFFF` / `#F0F0F0`) that secures buy-in for the 18-month integration roadmap (CDLC → SDLC → PDLC).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Architecture State
|
||||||
|
|
||||||
|
Active Layers (which exist and are stable):
|
||||||
|
|
||||||
|
[x] Core Primitives — `core/` (26 top-level modules + `core/lambda/` (8) + `core/metrics/` (10)): `abac_evaluator`, `attestation_matrix`, `auth_store`, `confidence_signal`, `contract_resolver`, `decommission_transform`, `env`, `env_transition`, `environment_check`, `hitl_gates`, `init_scaffold`, `jws_attestation`, `kms_signing`, `local_emulators`, `mode_resolver`, `onboarding`, `outbox_writer`, `output_publisher`, `pat_lifecycle`, `policy_engine`, `regression_verify`, `separation_of_duties`, `submission_readiness` + `core/lambda/` (6 modules) + `core/metrics/` (decision ledger)
|
||||||
|
|
||||||
|
[x] Domain Modules — `adapters/terraform/` (stateless adapter), `adapters/kyverno-json/` (unified policy engine, INV-4 swappable), `adapters/wiz/`, `adapters/kyverno/` (K8s, inactive for Terraform — D-053)
|
||||||
|
|
||||||
|
[x] API/Dev Surface — `nova/` CLI package (15 subcommands, argparse-only, `[project.scripts] nova = "nova.cli:main"`); `nova auth {login,revoke,status}`; `nova idp setup` (delegates to `terraform apply` per REQ-369); `nova init`; `nova apply --local`; `nova cli-action` composite action (GitHub only — D-232)
|
||||||
|
|
||||||
|
[x] UI/Agent Surface — N/A (no UI; CLI + JSON endpoints only; JWKS serves `application/json`)
|
||||||
|
|
||||||
|
Compute Topology (per environment):
|
||||||
|
|
||||||
|
local: abstract (local emulators via `core/local_emulators.py:LocalLambdaStub`; `nova apply --local` synthesizes env via `core/env.synthesize_local_env()`; no cloud provisioning)
|
||||||
|
|
||||||
|
dev: abstract (env JSON `core/environments/dev.json`; pilot ran `mode: full` against live AWS `581513795199` at v1.26; Nova-idp live deployment is via `nova-platform-ops` Terraform — M1 cutover pending operator action, covered-reference)
|
||||||
|
|
||||||
|
staging: N/A (no `staging` environment JSON; environments are dev/qa/prod/dr)
|
||||||
|
|
||||||
|
prod: UNKNOWN — needs investigation (env JSON `core/environments/prod.json` exists; live-apply not run against prod; pilot was dev-only per D-209)
|
||||||
|
|
||||||
|
dr: placeholder (env JSON `core/environments/dr.json` exists; blocked by pilot-readiness policy D-208; not activated)
|
||||||
|
|
||||||
|
Identity Stack in Force:
|
||||||
|
|
||||||
|
auth: Custom IDP — Nova-idp (`nova-idp-auth` Lambda, v1.28): sign-up/sign-in/session; Argon2id (t=3, m=65536, p=1); DynamoDB `nova-users`/`nova-sessions`/`nova-password-resets`. Covered-reference — live deployment via `nova-platform-ops` Terraform (M1 cutover pending operator action).
|
||||||
|
|
||||||
|
token-vend: Nova-idp (`nova-idp-token-vend` Lambda, v1.28, deployed on container image with static `kj` per REQ-363): accepts PAT/session → KMS-signed OIDC token (ECDSA P-256 / ES256); kyverno-json ABAC fail-closed (INV-17, C-6.1); `nova-pats` DynamoDB (strong-read revocation, D-229, 60s SLO). Covered-reference — M1.5 verification gate pending (3 consecutive green rebuilds).
|
||||||
|
|
||||||
|
signing: KMS asymmetric — `alias/nova-oidc-signing` (ECC_NIST_P256, SIGN_VERIFY, 90-day rotation, D-234). Code complete; key provisioning is covered-reference (REQ-362, M1 cutover pending operator action in `nova-platform-ops`).
|
||||||
|
|
||||||
|
session: DynamoDB — `nova-sessions` table (PK `session_id`, TTL `expires_at`, 24h). Cookie/local-file: `~/.nova/credentials.json` (0600, OIDC token + PAT metadata, NOT raw PAT — C-7.3).
|
||||||
|
|
||||||
|
Audit Stream:
|
||||||
|
|
||||||
|
source of truth: DynamoDB outbox → S3 Object Lock (7-yr target, D-083 deferred) → audit repo (hot index). The Decision Ledger (SQLite hash-chain, D-121, `core/metrics/decision_ledger.py`) is the cold store for `ai.decision.made` + `attestation.recorded` events.
|
||||||
|
|
||||||
|
in-repo fallback: yes (SQLite hash-chain outbox_writer, `core/outbox_writer.py`, INV-6 tamper-evident; tamper-*resistant* deferred — D-083 S3 Object Lock/JWS not yet enabled)
|
||||||
|
|
||||||
|
retention policy: 7 years (S3 Object Lock target; not yet enabled — D-083 deferred)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Technical Stack (concrete, not aspirational)
|
||||||
|
|
||||||
|
Language(s) and runtime(s): Python 3.12 (requires-python `>=3.12`; Lambda Python 3.12 runtime on Amazon Linux 2023); Go (kj binary, `CGO_ENABLED=0`, pinned v0.0.3 from `github.com/kyverno/kyverno-json`)
|
||||||
|
|
||||||
|
Build / packaging: setuptools (`pyproject.toml` v1.29.0, build-backend `setuptools.build_meta`); wheel via `python -m build --wheel`; Lambda layer via `pip install --target layer/python/` + `zip`; Lambda zip (`nova-lambda-token-vend-v1.29.x.zip`); ECR container image (`public.ecr.aws/lambda/python:3.12-al2023` base + static `kj` binary at `/opt/kj/kj`); publish to GitHub Releases per tag (D-232 — CodeArtifact out, direct GitHub Releases artifact fetch)
|
||||||
|
|
||||||
|
CI / CD: GitHub Actions only (D-232 — `.gitea/` removed, `forge_parity_disabled` CI assertion in `ci.yml`); `publish.yml` (tag-triggered `v1.29.*`, wheel + layer + Lambda zip + ECR image + GitHub Release, REQ-354); `ci.yml` (test/lint/forge-parity-disabled); `deploy.yml@v1.29` (consumer deploy); `nova cli-action` composite action (`.github/actions/nova-cli/action.yml`); OIDC to AWS (`id-token: write`); `nova-platform-ops` uses Gitea Actions (out-of-band, OPER-PRIV, TFM-HITL)
|
||||||
|
|
||||||
|
Infrastructure: AWS account `581513795199` (single-region `us-east-1`); S3 (state files); DynamoDB (locking + outbox + identity tables); Lambda (contract ingestor + Nova-idp 3 Lambdas on container images); KMS (per-stack CMK + `alias/nova-oidc-signing`); ECR (kj container image); CloudFront/WAF/ACM/Route53 (JWKS edge, covered-reference); Fargate (standby defensive fallback, covered-reference); no VMs/bare metal/OS (Anti-Goal)
|
||||||
|
|
||||||
|
Data stores: DynamoDB — `nova-contracts`, `nova-change-requests` (v1.7); `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` (v1.28); SQLite — Decision Ledger (`core/metrics/decision_ledger.py`, local cold store); S3 — Terraform state + audit Object Lock (target, D-083 deferred); ECR — kj container image
|
||||||
|
|
||||||
|
Secrets / KMS: KMS per-stack CMK (D-069, 90-day rotation); `alias/nova-oidc-signing` (ECC_NIST_P256, 90-day rotation, D-234 — covered-reference, M1 cutover pending); `nova-spike-runner` IAM user (static key, daily rotation via `workflows-src/rotate-aws-key.yml`, REQ-230 forge-agnostic); Secrets Manager (`nova/github-token`); `NOVA_FORGE_TOKEN` in `.env.secrets` (not shell-env, per bash_allowlist; used for `nova-platform-ops` Gitea releases)
|
||||||
|
|
||||||
|
External integrations in scope: GitHub (`acdl/acdl` — primary forge, D-232); Gitea (`git.cloudinit.dev/continuous-intelligence/nova-platform-ops` — ops repo, OPER-PRIV, out-of-band); AWS (account `581513795199` — pilot + identity stack + ECR); `kj` / kyverno-json v0.0.3 (Go binary, pinned SHA + repo URL in `platform/abac/kj-version.txt`, `github.com/kyverno/kyverno-json`); Marp CLI 4.5.0 + python-pptx (slides render pipeline, `docs/presentations/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Active Constraints (the load-bearing ones)
|
||||||
|
|
||||||
|
Locked Decisions: D-001..D-240 (full ledger in PROJECT.md + CLARIFY history). Load-bearing for new work: D-022 (contract schema), D-039/D-047 (per-run creds), D-051 (Lambda Function URL), D-069 (per-stack CMK), D-083 (S3 Object Lock — deferred), D-092 (local emulators), D-096 (live pilot — lifted v1.26), D-121 (Decision Ledger), D-133 (submission-readiness gate), D-200..D-213 (v1.26 pilot), D-214..D-225 (v1.27), D-226..D-231 (v1.28 — mode resolution, kyverno-json ABAC, Argon2id fail-closed, PAT revocation strong-read, JWKS function URL, ABAC policy git-SHA versioning), D-232..D-238 (v1.29 — forge parity abandoned, JWKS edge-only, KMS asymmetric, tag-pin handoff, cutover shape, Fargate sunset, KJ-LOCKSTEP), D-239 (ECR tag format), D-240 (Terraform precondition floor v1.2.0)
|
||||||
|
|
||||||
|
Active Invariants: INV-1..INV-18 (full text above). New in v1.28: INV-12 (mode observability), INV-13 (mode determinism), INV-14 (credential type encodes role), INV-15 (no AWS-managed identity), INV-16 (Argon2id password storage), INV-17 (ABAC discipline fail-closed). New in v1.29: INV-18 (JWKS-EDGE-ONLY) + 10 NFR constraints (KJ-STATIC, KJ-LOCKSTEP, KJ-WARMUP-HEALTH, OPER-PRIV, IAM-NARROW, DRIFT-DETECT, IMPORT-IDEMPOTENT, TFM-HITL, JWKS-SLO, JWKS-ROTATION)
|
||||||
|
|
||||||
|
Standing Capability Gate: CAP-001..CAP-041 — all Verified (32 from v1.0..v1.27 + 6 from v1.28 + 3 from v1.29 covered-reference). Gate enforced by `core/regression_verify.py` + CI merge gates. CAP-033..038 added v1.28 (CLI surface, delegation AST, layer/wheel match, auth flow, KMS sign, PAT revocation). CAP-039..041 added v1.29 (platform-ops-reposplit, kj-substrate-lockstep, jwks-edge-only — covered-reference, live cutover pending operator action in nova-platform-ops).
|
||||||
|
|
||||||
|
Anti-Goals Touched: `docs/vision.md` §7 / `NORTH_STAR.md` §Anti-Goals — (1) not an upstream dev platform; (2) not a general-purpose AI; (3) not a legacy infra bridge; (4) not a permissive delivery highway; (5) not a mutable audit log. v1.29 honored all 5 (no PDLC reach, narrow CLI autonomy, no VMs, ABAC fail-closed + HITL gates intact, immutable outbox).
|
||||||
|
|
||||||
|
Out-of-Scope (hard): MFA/TOTP enforcement (v1.21+); WebAuthn/FIDO2 (v1.23+); upstream IdP federation (v1.23+); Lambda layer auto-update on `core/` changes (v1.19); password breach detection (v1.23+); session refresh token rotation (v1.22); S3 Object Lock / JWS tamper-resistance (D-083, deferred); multi-cloud (Azure/GCP); ML forecasting; bonds/derivatives/options (D-200 equities-only); multi-validator BFT (D-201 single-validator PoA); pilot qa/prod/dr environment activation (D-208/D-209, separate initiative); CodeArtifact provisioning (out per D-232 — direct GitHub Releases artifact fetch); Nova-idp feature work (new OIDC claims, new ABAC rules — bring live, don't extend); CloudFront Frontend / L3B consumer surface (pure ops focus only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Recent History & Quality Gates (last 1-2 milestones)
|
||||||
|
|
||||||
|
Last Shipped: v1.29 (tag `v1.28.6`, 2026-08-20) — Reposplit + Identity Layer Bring-Live. 6 acdl-side REQs complete (REQ-354, 367, 368, 369, REQ-OPS-GUIDE, REQ-CONSUMER-BUMP) + 14 covered-reference REQs (355-366, 371). 3 CAPs (CAP-039..041), 1 INV (INV-18), 10 NFR constraints, 9 decisions (D-232..240). 7 phases (P0 + P1..P5 + P6 final). Grill PROCEED 0.72 (4 critical fixes). Review PASS-WITH-ISSUES (3 P0 fixes). Audit all PASS. Merged to main `9dc5669`, pushed + 8 Gitea releases created (ids 803-810).
|
||||||
|
|
||||||
|
In Progress: N/A (no phase in progress; v1.29 complete; next milestone not yet scoped — this intake initiates the leadership deck initiative)
|
||||||
|
|
||||||
|
Coverage Floor: 73.8% (3119/4227 lines covered) — BELOW the 80% release-gate floor. v1.29 was a feature milestone (no NFR coverage work); v1.28 new modules have high unit-test coverage but the overall floor is dragged by older uncovered code paths. Quality debt to address in a future NFR milestone. YELLOW carried without scope expansion.
|
||||||
|
|
||||||
|
Recent Incidents: none (no incidents in v1.28 or v1.29; no hotfix/rollback/outage commits in recent history)
|
||||||
|
|
||||||
|
Known Tensions: (1) nova-platform-ops repo not yet created — the 14 covered-reference REQs (355-366, 371) have their acdl-side deliverables complete (operator guide, publish.yml, CFN archive) but the live M1/M1.5/M2 cutover gates have not been run (operator action, out-of-band). (2) Coverage 73.8% < 80% floor — YELLOW carried from v1.28; v1.29 did not expand scope but did not restore the floor. (3) M-001 (ABAC empty-policy-dir fail-open gap) — pinned in `test_abac_e2e.py`, mitigated; clear fix exists (treat `any_policy=False` as fail-closed) but not yet applied. (4) Q7 carry-forward (kj image verification — M1.5 3-consecutive-rebuild gate is operator action in nova-platform-ops CI, not acdl-side). (5) The existing 23-slide deck (`docs/presentations/nova-autonomous-cloud-delivery-marp.md`) is too long for the leadership audience (target ≤7 slides).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Agent Context & Assumptions (Agent Initiators Only)
|
||||||
|
|
||||||
|
Missing Context: (1) The 18-month roadmap specifics — NORTH_STAR.md §Future Horizons has the strategic direction (CDLC→SDLC→PDLC integration, AI-Agent Intent Share ≥40%) but the PO needs to define the concrete milestone sequence for the deck. (2) Target audience specifics — "Technology Leadership" is the stated audience but the deck needs to know if this is CTO-level, VP-level, or Director-level (affects depth + framing). (3) Live AWS verification of covered-reference REQs — nova-platform-ops not yet created; M1/M1.5/M2 cutover gates not yet run.
|
||||||
|
|
||||||
|
Agent Assumptions: (1) The PDLC trigger is the post-v1.29 state intake + the PO's new initiative (leadership deck). (2) The deck uses the existing S&P theme (`docs/presentations/assets/nova-sp-theme.css`, palette `#D6002A`/`#1B1B1B`/`#FFFFFF`/`#F0F0F0`) + the existing Marp + python-pptx render pipeline (`workflows-src/slides.yml`, `scripts/render_pptx.py`). (3) **OVERRIDDEN by D-241 (v1.30 CLARIFY):** the leadership deck is a **discrete, hand-authored artifact — NOT a compression** of the 23-slide existing citizen-developer deck (`nova-autonomous-cloud-delivery-marp.md`), which remains untouched. The Slide Content Map in PROJECT.md §v1.30 is hand-authored content, not derived. (4) Coverage 73.8% is reported as YELLOW system health (below 80% floor) but is not a blocker for the deck initiative — it's quality debt for a future NFR milestone. (5) The covered-reference REQs are reported as tensions, not blockers — they have acdl-side deliverables complete + documented cutover gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Canonical State References (Version/Hash)
|
||||||
|
|
||||||
|
Vision/Strategy doc: `docs/vision.md` v0.2 (referenced in PROJECT.md; not version-tagged separately)
|
||||||
|
|
||||||
|
Architecture document: `.ciagent/ARCHITECTURE.md` §12.1..§12.11 (v1.29-appended §12.11 Platform Ops Reposplit); commit `9dc5669` (main HEAD)
|
||||||
|
|
||||||
|
Last approved SPEC: v1.29 (REQ-354..369, 371, 363b, REQUIREMENTS.md §v1.29); commit `9dc5669`
|
||||||
|
|
||||||
|
Decision log: D-001..D-240 (PROJECT.md load-bearing + CLARIFY.md history); last synced commit `9dc5669`
|
||||||
|
|
||||||
|
Invariants catalog: INV-1..INV-18 (STATE.md §Invariants); commit `9dc5669`
|
||||||
|
|
||||||
|
Capability catalog: CAP-001..CAP-041 (STATE.md §Domains 1..12); commit `9dc5669`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ground rules compliance
|
||||||
|
|
||||||
|
1. No prose paragraphs inside sections — field structure used throughout. ✓
|
||||||
|
2. No aspirational items — state is what is (nova-platform-ops "not yet created", prod "UNKNOWN", coverage "73.8%"). ✓
|
||||||
|
3. No restated decisions — referenced D-*/INV-*/CAP-* IDs only. ✓
|
||||||
|
4. Unknowns explicit — "UNKNOWN — needs investigation" used for prod state; "covered-reference" used for live-unverified REQs. ✓
|
||||||
|
5. One file, one format — appended to STATE.md as §PDLC Phase 0 Intake. ✓
|
||||||
|
6. Full shipping workflow + merge to forge upstream, NO release — commit to main + push only (release skipped per instruction). ✓
|
||||||
@@ -0,0 +1,945 @@
|
|||||||
|
# Nova — Architecture (v1.1 target)
|
||||||
|
|
||||||
|
> Target architecture for the real Agentic Cloud Delivery Platform (rebranded
|
||||||
|
> Nova in v1.15). Source of truth for **how**: `docs/architecture.md` (v0.2) is the upstream
|
||||||
|
> draft; this file is the Nova-repo operating copy, refined at phase
|
||||||
|
> boundaries. Where this file and `docs/vision.md` conflict, the vision wins.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Architecture is at **v0.2** upstream (`docs/architecture.md`). Milestone v1.1
|
||||||
|
**finalizes it to v1.0** in Phase 07 by resolving the 11 open decisions
|
||||||
|
(see `PROJECT.md` open-decision resolutions table). This file records the
|
||||||
|
locked commitments and the v1.1 spike scope.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The platform is **four layers + six cross-cutting concerns**. The sixth
|
||||||
|
concern — the engine abstraction (§12) — is first-class, not an
|
||||||
|
implementation detail. The vision's "Two Consumer Surfaces, One Platform"
|
||||||
|
tenet binds everything: L3A and L3B converge on the same contract schema,
|
||||||
|
the same policy envelope, and the same evidence stream.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── acdl-contracts ────────────┐
|
||||||
|
Developer ───▶ │ commit contract.yaml │ (L3A)
|
||||||
|
Citizen dev ──▶ │ Issue → agent → contract.yaml │ (L3B)
|
||||||
|
└────────────────┬───────────────────────┘
|
||||||
|
│ (push)
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ central pipeline │
|
||||||
|
│ (acdl repo, Gitea │
|
||||||
|
│ Actions / act_runner) │
|
||||||
|
└────────┬─────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────┼─────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
contract→IR resolution policy (Checkov/Kyverno) confidence signal
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
Terraform adapter ──▶ terraform plan ──▶ PolicyCheckResult ──▶ {score,band}
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
dev (autonomous, ≥0.50) qa (HITL, ≥0.75) prod (HITL, ≥0.90) dr (HITL, ≥0.95)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DynamoDB outbox ──▶ S3 Object Lock (7-yr, source of truth) ──▶ GitHub audit repo (hot index)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
acdl-evidence (timeline UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
### Layer 1 — Foundational Primitives
|
||||||
|
Single-purpose, **engine-agnostic** primitive modules. L1 modules do
|
||||||
|
not compose with other L1s; L1 takes its environment as input. The L1
|
||||||
|
interface is defined against the **Target Stack IR**, not against Terraform
|
||||||
|
directly (the IR is shaped to round-trip to Terraform in v1, per §12.1).
|
||||||
|
|
||||||
|
- No inter-L1 references. L1 may call Terraform data sources.
|
||||||
|
- Semver: interface → MAJOR, behavior → MINOR, lifecycle → PATCH (W3.D).
|
||||||
|
- Immutability on publication. 12-month deprecation window.
|
||||||
|
- AI refinement is a flag; the trigger is the W1.A joint condition.
|
||||||
|
|
||||||
|
### Layer 2 — Composed Stacks
|
||||||
|
Combine L1 primitives into deployable shapes. Each codebase maps to one
|
||||||
|
canonical L2 stack (`multiStack: true` only per W1.B). Shape X
|
||||||
|
(parameterized module) or Shape Y (thin-composition layer). Hierarchical
|
||||||
|
composition, max depth 5, only registered L1s. The thin-composition tree's
|
||||||
|
`wires` field is defined against the IR's relationship type, not a Terraform
|
||||||
|
module block.
|
||||||
|
|
||||||
|
Pipeline quality checks: secrets-in-plaintext, public ingress, IAM
|
||||||
|
wildcard, KMS key reference, tag compliance, naming convention. Restricted
|
||||||
|
from thin-composition: IAM principal creation, network boundary creation,
|
||||||
|
key/secret creation, external data transfer. Auto-promote after 3 observed
|
||||||
|
usages.
|
||||||
|
|
||||||
|
### Layer 3A — Developer Consumer Surface
|
||||||
|
Tag-based reference to the central pipeline template. Developer-owned
|
||||||
|
workflow file, no platform auto-sync. L3A and L3B are parallel paths, not a
|
||||||
|
progression. **W2.A (Path B):** tag for dev/qa, SHA for prod; platform CLI
|
||||||
|
resolves tag→SHA for prod-bound workflows.
|
||||||
|
|
||||||
|
### Layer 3B — Agentic Consumer Surface
|
||||||
|
Hybrid runtime, skill as markdown, agent as executor. Trust model: trust
|
||||||
|
and always verify on the platform side. Skill envelope (4 dimensions).
|
||||||
|
Stateless agents, all state in the platform. `profile: agentic` marker
|
||||||
|
unlocks `naturalLanguageIntent`, `confidenceAtSubmission`, `agentTrace`.
|
||||||
|
Initial skill catalog (BA.A): web API, worker, scheduled job, static asset,
|
||||||
|
basic observability bootstrap.
|
||||||
|
|
||||||
|
Environment progression:
|
||||||
|
|
||||||
|
| Environment | Autonomy | Attester | Gate |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dev | Full autonomy (no HITL) | — | Confidence ≥ 0.50, all six inputs present |
|
||||||
|
| qa | Held for attestation | QA | GitHub Deployment approval + full QA matrix (§10) |
|
||||||
|
| prod | Held for attestation | SRE | GitHub Deployment approval + full SRE matrix (§10) |
|
||||||
|
| dr | Held for attestation | SRE | GitHub Deployment approval + dr-drill evidence |
|
||||||
|
|
||||||
|
**Staging is removed.** Dev is the only autonomous environment.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
### Central pipeline template (§6)
|
||||||
|
JSON Schema (draft 2020-12) with a thin domain wrapper. Central repo +
|
||||||
|
generated client libraries. Multi-stage validation: schema → policy → NFR →
|
||||||
|
confidence. Distributed enrichment. GitOps reconciler (K8s API; cdlc-gitops
|
||||||
|
state → CRDs) + Terraform execution layer (§12.5). The pipeline emits one
|
||||||
|
`PolicyCheckResult` per policy rule; the confidence signal consumes them as
|
||||||
|
one normalized input.
|
||||||
|
|
||||||
|
### Contract schema (§7)
|
||||||
|
Central repo + generated client libraries. Strict fail-fast at schema
|
||||||
|
stage, multi-stage validation with reason codes from a published
|
||||||
|
vocabulary. **W3.E:** per-env mandatory inputs —
|
||||||
|
- dev: `stack`, `environment`
|
||||||
|
- qa adds: `validation.e2eSuite`, `validation.loadTest`
|
||||||
|
- prod adds: `runbook`, `dashboard`, `oncall`
|
||||||
|
- dr adds: `drDrillRef`
|
||||||
|
- `inputs` always optional; `profile: agentic` fields optional everywhere.
|
||||||
|
|
||||||
|
### Confidence signal (§8)
|
||||||
|
Six canonical inputs, weighted sum with per-input breakdown. Per-env
|
||||||
|
thresholds: dev ≥ 0.50, qa ≥ 0.75, prod ≥ 0.90, dr ≥ 0.95. Structured output
|
||||||
|
`{ score, band, perInput, reasonCodes }`. 1-year storage, no retraining in
|
||||||
|
v1. Halt with explicit reason on missing input.
|
||||||
|
|
||||||
|
Policy input = list of `PolicyCheckResult` records (engine-agnostic).
|
||||||
|
Severity → penalty: critical → hard override to mandatory block; high →
|
||||||
|
-0.2; medium → -0.05; low → -0.01; info → 0.0. One critical finding
|
||||||
|
hard-overrides the score regardless of all other inputs.
|
||||||
|
|
||||||
|
**BA.B:** thresholds frozen for v1; tuning begins v1.2 (quarterly FP/FN
|
||||||
|
tracking; override = Infra & Ops + SRE joint sign-off, itself a
|
||||||
|
confidence-event).
|
||||||
|
|
||||||
|
### Audit and evidence stream (§9)
|
||||||
|
Tiered ledger: **S3 with Object Lock in compliance mode** (cold, source of
|
||||||
|
truth, 7-year retention) + **GitHub audit repo** (`acdl-evidence`, hot
|
||||||
|
query index, not part of the chain). Daily checkpoints. Event schema: JWS
|
||||||
|
detached signature, `prev_event_hash` chain, controlled-vocabulary
|
||||||
|
`event_type`. Outbox pattern: local durable outbox + async worker.
|
||||||
|
|
||||||
|
Outbox database = **DynamoDB**. RPO = 0 (synchronous write to local outbox
|
||||||
|
before contract submission ack); RTO = async worker's dead-letter recovery.
|
||||||
|
Single-region in v1. The outbox also stores per-contract QA and prod
|
||||||
|
approver identities (the only durable record outside GitHub's audit log).
|
||||||
|
|
||||||
|
### Human-in-the-Loop mechanics (§10)
|
||||||
|
Pre-execution gates. qa, prod, dr are PR-based attestation gates backed by
|
||||||
|
GitHub Environments with required reviewers. No partial deployment to roll
|
||||||
|
back on rejection (qa, prod); dr is a separate GitHub Deployment against a
|
||||||
|
separate cluster/region.
|
||||||
|
|
||||||
|
Reviewer routing: GitHub CODEOWNERS + Environment required reviewers
|
||||||
|
(qa → QA; prod → SRE; dr → SRE). CODEOWNERS routes, does not enforce
|
||||||
|
identity distinctness.
|
||||||
|
|
||||||
|
**Separation of duties** (platform-internal, not GitHub-native, not Kyverno
|
||||||
|
in v1): on dev→qa promotion the platform writes the QA approver's GitHub
|
||||||
|
identity to the DynamoDB outbox keyed by `contractId`; on qa→prod it reads
|
||||||
|
the stored QA approver and the new SRE approver; if equal, it blocks, emits
|
||||||
|
`SEPARATION_OF_DUTIES_VIOLATION`, and routes a halt artifact to SRE on-call.
|
||||||
|
|
||||||
|
Full 8-concern attestation matrix (functional, performance, security
|
||||||
|
posture, contract NFRs, operational readiness, incident response,
|
||||||
|
capacity/cost, resilience) — see `docs/architecture.md` §10.4.
|
||||||
|
|
||||||
|
Timeout: 1 business day = warn + escalate; 2 business days = auto-freeze +
|
||||||
|
re-submit (linked via `supersedes`). Rejection returns the contract to HELD;
|
||||||
|
the audit chain is extended, not torn up.
|
||||||
|
|
||||||
|
### Agentic stack (§11)
|
||||||
|
Hybrid runtime: platform-managed control plane + consumer-owned agent.
|
||||||
|
Versioned, signed skill catalog over MCP. Skill envelope enforced on
|
||||||
|
invocation and result submission. Consumer-owned skill execution; the
|
||||||
|
platform does not run the skill. Stateless agents, all state in the
|
||||||
|
platform. Skills are reviewed for sensitive data before release (Infra &
|
||||||
|
Ops owns the review; it is the mandatory release gate).
|
||||||
|
|
||||||
|
### Angine execution (§12) — the binding constraint
|
||||||
|
**Target Stack IR** (locked): a engine-neutral description of resources
|
||||||
|
(typed inputs/outputs/NFRs), relationships (single parent per child),
|
||||||
|
composition (tree, max depth 5), and policy hooks. The L1 registry, L2
|
||||||
|
thin-composition tree, contract YML, and PolicyCheckResult schema are all
|
||||||
|
defined against the IR — none against any specific engine.
|
||||||
|
|
||||||
|
**Angine adapters** are the only engine-specific code. An adapter
|
||||||
|
compiles the IR into a engine execution plan. **v1 ships exactly one
|
||||||
|
adapter: the Terraform adapter.** v2+ may add OpenTofu, Pulumi, K8s CRDs
|
||||||
|
without architectural change.
|
||||||
|
|
||||||
|
v1 reality: the IR is shaped to round-trip cleanly to Terraform (nearly
|
||||||
|
isomorphic). As more adapters appear, the IR gets more expressive and the
|
||||||
|
adapters gain translation logic; the L1 content, the YML standard, and the
|
||||||
|
thin-composition tree do not change.
|
||||||
|
|
||||||
|
**Terraform adapter (v1):** translates IR-typed L1 interface → Terraform
|
||||||
|
`variable`/`output` blocks; IR-typed L2 thin-composition tree → Terraform
|
||||||
|
root module; IR-typed relationships → module references; emits a
|
||||||
|
`terraform plan` from the IR. The adapter is a thin layer; it does not own
|
||||||
|
L1/L2 content.
|
||||||
|
|
||||||
|
State storage: S3 (state) + DynamoDB (locking), cloud-managed,
|
||||||
|
single-region in v1.
|
||||||
|
|
||||||
|
Policy toolchain: **Checkov** for Terraform plan policy (the L2 checks +
|
||||||
|
tag/naming); **Kyverno** for K8s-native/platform-internal policy; **OPA**
|
||||||
|
reserved for cross-resource cases, explicitly last resort.
|
||||||
|
|
||||||
|
**Policy result normalization (§12.6):** the confidence signal consumes a
|
||||||
|
normalized `PolicyCheckResult` schema, not raw engine output.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contractId": "uuid",
|
||||||
|
"evaluatedAt": "ISO-8601",
|
||||||
|
"engine": "checkov | kyverno | opa",
|
||||||
|
"ruleId": "CKV_AWS_24 | KYVERNO_NO_PRIVILEGED | ...",
|
||||||
|
"severity": "critical | high | medium | low | info",
|
||||||
|
"result": "pass | fail | skipped | error",
|
||||||
|
"message": "human-readable",
|
||||||
|
"evidence": { "...engine-specific, opaque to the signal..." },
|
||||||
|
"resourceRef": "IR-typed resource identifier"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Execution layer: GitHub/Gitea Actions in the central pipeline repo. State
|
||||||
|
locking via DynamoDB. **AWS credentials via OIDC federation — long-lived
|
||||||
|
credentials are forbidden** (§12.5). The platform does not run
|
||||||
|
`terraform apply` against a developer's workstation; all execution is in
|
||||||
|
the central pipeline.
|
||||||
|
|
||||||
|
Registry maintenance: L1 publication updates the L1 registry in the same
|
||||||
|
PR. The registry is the IR-typed contract, not a Terraform-specific
|
||||||
|
variable schema.
|
||||||
|
|
||||||
|
Contract→IR resolution: the contract declares intent in IR-typed terms;
|
||||||
|
the pipeline resolves it to a target stack (list of L1 instances + inputs +
|
||||||
|
relationships); the Terraform adapter compiles the target stack to a plan.
|
||||||
|
|
||||||
|
## v1.1 spike scope
|
||||||
|
|
||||||
|
The spike (Phases 08–10) materializes the **minimum** that proves the IR
|
||||||
|
commitments hold (no polyglot mess):
|
||||||
|
|
||||||
|
- One L1: `l1-s3` (IR-typed interface; the only AWS resource in the spike).
|
||||||
|
- One L2 thin-composition: `l2-static-assets` (references `l1-s3` only).
|
||||||
|
- Terraform adapter: IR → `terraform plan` against AWS via OIDC.
|
||||||
|
- One contract submission → contract→IR → `terraform plan` → Checkov
|
||||||
|
`PolicyCheckResult` → confidence signal → evidence event to the DynamoDB
|
||||||
|
outbox.
|
||||||
|
- State: S3 + DynamoDB (real AWS, single-region).
|
||||||
|
|
||||||
|
Out of spike scope: full HITL matrix wiring, Kyverno, OPA, MCP skill
|
||||||
|
catalog, GitOps reconciler, multi-region, prod/dr environments, the 5-skill
|
||||||
|
L3B catalog. Those are post-spike (v1.2+) platform build-out.
|
||||||
|
|
||||||
|
## Gitea API surface (carried from v1.0, refined)
|
||||||
|
|
||||||
|
| Capability | Gitea support | ACDL approach (v1.1) |
|
||||||
|
|------------|---------------|----------------------|
|
||||||
|
| Org-scoped repo create | `POST /api/v1/orgs/{org}/repos` | Used for any new repos |
|
||||||
|
| Native Pages | **None** | Serve `acdl-evidence` via raw file URLs (unchanged from v1.0) |
|
||||||
|
| Environments API | **None**; act_runner ignores `environment:` | Model HITL gates via `workflow_dispatch` approval inputs (v1.0 D-013 pattern) — **refined in Phase 07** for the real pre-execution gate model |
|
||||||
|
| `repository_dispatch` | Not supported | Cross-repo trigger via `workflow_dispatch` API (unchanged) |
|
||||||
|
| Reusable workflows | Supported | `acdl/.gitea/workflows/pipeline.yml` via `uses: ...@<ref>` |
|
||||||
|
| `id-token: write` / OIDC | **Not supported** (RESEARCH TARGET 1, conf 0.95). Gitea docs list `id-token` as an unsupported GitHub-only scope; open proposal go-gitea/gitea#33681; draft PR go-gitea/gitea#36988 unmerged. Even Gitea's own CI uses long-lived AWS keys (issue #37980). | **Spike waiver D-039:** per-run-rotated long-lived key (rotated after each run by `scripts/rotate_spike_key.sh`). Real OIDC deferred to v1.2, blocked on PR #36988. |
|
||||||
|
| `actions/configure-aws-credentials` | Unusable without OIDC | Spike uses static AWS creds from a (rotated) Gitea Actions secret via the `aws-actions/configure-aws-credentials@v4` `access-key-id`/`secret-access-key` inputs, or plain `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars. v1.2 switches to `role-to-assume` when OIDC lands. |
|
||||||
|
|
||||||
|
### Branch pinning rule (refined for W2.A)
|
||||||
|
|
||||||
|
- Dev/qa contracts reference the reusable workflow by **tag**
|
||||||
|
(`@v1.1-spike`).
|
||||||
|
- Prod-bound workflows reference by **SHA**; the platform CLI
|
||||||
|
(`platform/cli/resolve-tag.ts`, Phase 07) resolves the current tag to its
|
||||||
|
SHA. (Spike scope: the CLI is a stub; the real CLI lands in v1.2.)
|
||||||
|
|
||||||
|
### Verification toolchain
|
||||||
|
|
||||||
|
ACDL has no `package.json`. The verification gate substitutes:
|
||||||
|
- **typecheck:** `terraform validate`, `python3 -m py_compile`, JSON Schema
|
||||||
|
validation (`ajv` or `python -m jsonschema`) against `schemas/`.
|
||||||
|
- **test:** per-phase `scripts/verify_phaseNN.sh` (Phase 06: archive integrity;
|
||||||
|
Phase 07: schema validation + decision-resolution completeness; Phase 08:
|
||||||
|
OIDC assume-role + state backend; Phase 09: IR + L1 + adapter `terraform
|
||||||
|
plan`; Phase 10: end-to-end contract submission).
|
||||||
|
- **build:** `terraform init` (real build for the spike).
|
||||||
|
- See `PERSONAS.md` verification_toolchain.
|
||||||
|
|
||||||
|
## Build order (v1.1)
|
||||||
|
|
||||||
|
1. Phase 06 — archive demo, reorient repo.
|
||||||
|
2. Phase 07 — finalize architecture v1.0; author schemas + designs.
|
||||||
|
3. Phase 08 — AWS OIDC bootstrap (use temp key once, rotate).
|
||||||
|
4. Phase 09 — IR + `l1-s3` + Terraform adapter → `terraform plan`.
|
||||||
|
5. Phase 10 — `l2-static-assets` + contract→IR → end-to-end spike.
|
||||||
|
6. COMPLETE gate — review → ship `v1.2.0` → audit. **DONE.**
|
||||||
|
|
||||||
|
## v1.2 build-out scope
|
||||||
|
|
||||||
|
v1.2 takes the v1.1 spike (dev-only, `plan`-only, single S3 L1) to a real,
|
||||||
|
simpler, better-documented platform that delivers a microservice to AWS ECS
|
||||||
|
Fargate end-to-end. The locked architecture (§1–§12) is unchanged — v1.2
|
||||||
|
extends the *implementation*, not the design.
|
||||||
|
|
||||||
|
### In scope (five axes, user-directed 2026-07-21)
|
||||||
|
|
||||||
|
1. **Re-evaluate the current state.** go-gitea/gitea#36988 (OIDC for Gitea
|
||||||
|
Actions) re-checked 2026-07-21: still **open** (last updated 2026-05-27,
|
||||||
|
not merged). Real OIDC remains deferred to v1.3+; v1.2 extends the D-039
|
||||||
|
per-run-rotated-key waiver as **D-047**. The waiver continues to satisfy
|
||||||
|
§12.5's *intent* (no *persistently* long-lived key): the spike key is
|
||||||
|
rotated after each run by `scripts/rotate_spike_key.sh`, and Phase 12
|
||||||
|
tightens the IAM scoping + rotation hygiene.
|
||||||
|
2. **NFR improvements on the existing spike.** Least-privilege IAM audit of
|
||||||
|
`spike_runner_policy.json`; idempotent `create_state_backend.py` /
|
||||||
|
`create_iam_user.py`; proper exit codes / error handling; P1-1 redaction
|
||||||
|
(two AWS access key IDs in `.ciagent/VERIFY.md` Phase 09 narrative).
|
||||||
|
3. **Streamline / simplify the current setup.** Consolidate
|
||||||
|
`run_spike_plan.sh` + `run_spike_e2e.sh` into one
|
||||||
|
`scripts/run_platform.sh`; remove dead code and stale `platform/` paths.
|
||||||
|
4. **README.md fully up to date on how the platform works.** Reflect v1.1
|
||||||
|
complete; document the actual spike flow, `scripts/run_platform.sh`, the
|
||||||
|
real repo layout, and the v1.2 objective.
|
||||||
|
5. **Bootstrap a consumer repo with a basic microservice deployed to ECS
|
||||||
|
end-to-end.** New Gitea repo `acdl-consumer-microservice` (org
|
||||||
|
`continuous-intelligence`); new IR-typed L1s (`l1-vpc`, `l1-ecs-cluster`,
|
||||||
|
`l1-ecs-service`, `l1-iam-role`, `l1-alb`, `l1-ecr`); new
|
||||||
|
`l2-microservice` thin-composition; one contract submission →
|
||||||
|
`terraform apply` (dev, autonomous per §10, confidence ≥ 0.50) → a live
|
||||||
|
ECS Fargate service serving HTTP 200 → evidence event to the DynamoDB
|
||||||
|
outbox → acdl-evidence timeline.
|
||||||
|
|
||||||
|
### Angine extension (ECS Fargate)
|
||||||
|
|
||||||
|
The Terraform adapter (§12) remains the only engine-specific code. v1.2
|
||||||
|
expands the adapter `TYPE_MAP` to cover the six new ECS-shaped IR resource
|
||||||
|
types. The L1 interface shape (IR-typed inputs/outputs/NFRs, registered in
|
||||||
|
`modules-ir/registry.json`) is unchanged — only the set of registered L1s
|
||||||
|
grows. The IR commitments (REQ-28) continue to hold: `modules-ir/`,
|
||||||
|
`schemas/`, `contracts/`, `core/confidence_signal.py`,
|
||||||
|
`core/contract_resolver.py`, `core/outbox_writer.py`
|
||||||
|
remain engine-agnostic.
|
||||||
|
|
||||||
|
### `terraform apply` (dev only)
|
||||||
|
|
||||||
|
v1.2 lifts the engine execution from `plan` to `apply` for the `dev`
|
||||||
|
environment only. Dev is autonomous per §10 (confidence ≥ 0.50, no HITL).
|
||||||
|
`apply` for qa/prod/dr remains HITL-gated and out of scope for v1.2. The
|
||||||
|
apply result (resources created, plan diff) is captured in the evidence
|
||||||
|
stream as a `terraform.apply` event.
|
||||||
|
|
||||||
|
### Out of scope for v1.2 (deferred to v1.3+)
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| Real OIDC federation | go-gitea/gitea#36988 still open. v1.2 extends D-039 waiver (D-047); real OIDC is v1.3+. |
|
||||||
|
| Full HITL matrix wiring (qa/prod/dr) | v1.2 is dev-only autonomous `apply`; HITL wiring is v1.3. |
|
||||||
|
| Kyverno + OPA policy engines | v1.2 keeps Checkov only; Kyverno/OPA are v1.3. |
|
||||||
|
| MCP skill catalog + real L3B agent | v1.2 keeps the L3B stub; the 5-skill catalog is v1.3. |
|
||||||
|
| Audit ledger build-out (S3 Object Lock + JWS + async worker + DLQ + daily checkpoints) | v1.2 keeps the v1.1 outbox; the regulatory ledger is v1.3. |
|
||||||
|
| Multi-region state / outbox | Single-region in v1 (§9, §12.3); multi-region is v1.3+. |
|
||||||
|
| Prod/dr environments | v1.2 is dev-only; prod/dr are v1.3. |
|
||||||
|
| GitOps reconciler (ArgoCD/Flux) | v1.3+. |
|
||||||
|
|
||||||
|
## Build order (v1.2)
|
||||||
|
|
||||||
|
1. Phase 11 — re-eval #36988 + NFR audit + simplification findings + README rewrite.
|
||||||
|
2. Phase 12 — NFR harden + simplify (idempotent bootstrap, one `run_platform.sh`, IAM audit, redactions).
|
||||||
|
3. Phase 13 — six ECS L1s + adapter `TYPE_MAP` expansion.
|
||||||
|
4. Phase 14 — `l2-microservice` + contract schema extension.
|
||||||
|
5. Phase 15 — consumer repo + `terraform apply` (dev) → live ECS service.
|
||||||
|
6. Phase 16 — capstone e2e: consumer commit → live HTTP 200 → evidence → timeline.
|
||||||
|
7. COMPLETE gate — review → ship `v1.3.0` → audit.
|
||||||
|
|
||||||
|
## v1.8 Architecture Addendum
|
||||||
|
|
||||||
|
> Milestone v1.8 (complete, tag `v1.8.0`). Adds encryption-by-default,
|
||||||
|
> deletion-protection-by-default, uptime monitoring, decommission alias,
|
||||||
|
> engineering standards, and path documentation.
|
||||||
|
|
||||||
|
### New Primitives
|
||||||
|
|
||||||
|
- **`kms-key`** (`aws:kms:key`) — Per-stack customer-managed KMS key with
|
||||||
|
`enable_key_rotation = true`. One key per L2 deployment (no shared keys).
|
||||||
|
Wired into both L2 compositions as a child, with its `kms_key_arn` output
|
||||||
|
connected to all children's `kms_key_arn` input. Adapter emits
|
||||||
|
`aws_kms_key` + `enable_key_rotation`.
|
||||||
|
- **`uptime`** (`aws:ecs:uptime-service`) — Uptime-kuma on ECS Fargate with
|
||||||
|
a feature flag (`feature_flag_enabled`), monitored endpoints (HTTP/DNS/TCP),
|
||||||
|
alert channels (Teams/email/SMS/GitHub issues). Deployed by default after
|
||||||
|
any L2 module with a separate terraform state. When the feature flag is
|
||||||
|
false, the adapter emits no resources.
|
||||||
|
|
||||||
|
### Encryption by Default
|
||||||
|
|
||||||
|
All 12 L1 primitives have `encryption_enabled` NFR (default true). Primitives
|
||||||
|
with at-rest data (s3, rds, ecr, ecs-service, ecs-cluster) have an optional
|
||||||
|
`kms_key_arn` input. The adapter emits encryption blocks (SSE-KMS for S3,
|
||||||
|
storage_encrypted for RDS, encryption_configuration for ECR) referencing the
|
||||||
|
per-stack CMK when provided. Managed KMS fallback with stderr warning for
|
||||||
|
standalone L1 deployments.
|
||||||
|
|
||||||
|
### Deletion Protection by Default
|
||||||
|
|
||||||
|
All 12 L1 primitives have `deletion_protection` NFR (default true). The
|
||||||
|
adapter emits `lifecycle { prevent_destroy = true }` when true. L2 modules
|
||||||
|
expose a `features.deletion_protection` flag (default true) propagated to
|
||||||
|
all children via the resolver. Setting `inputs.deletion_protection: false`
|
||||||
|
in the contract disables it for the whole stack.
|
||||||
|
|
||||||
|
### Decommission Alias
|
||||||
|
|
||||||
|
A `mode: decommission` on the deploy pipeline implements a 2-step destroy:
|
||||||
|
1. Disable deletion protection (resolve with `deletion_protection: false`,
|
||||||
|
terraform plan/apply, HITL SRE gate via GitHub environment).
|
||||||
|
2. Zero counts + destroy (`decommission_transform` zeroes all scalable counts,
|
||||||
|
terraform plan/apply, second HITL SRE gate).
|
||||||
|
|
||||||
|
CMDB validation via DynamoDB `acdl-change-requests` table. The Lambda
|
||||||
|
`validate_change_request` action queries the table and asserts
|
||||||
|
`status == "approved"` + `consumerRepo` match.
|
||||||
|
|
||||||
|
### Adapter Expansion
|
||||||
|
|
||||||
|
TYPE_MAP grew from 16 to 19 entries (+ `aws:kms:key`, `aws:kms:alias`,
|
||||||
|
`aws:ecs:uptime-service`). Specialized emission branches added for KMS key
|
||||||
|
rotation, S3 SSE-KMS configuration, uptime ECS Fargate task, and
|
||||||
|
`prevent_destroy` lifecycle on all resources.
|
||||||
|
|
||||||
|
### Pipeline Stages
|
||||||
|
|
||||||
|
The deploy pipeline grew from 8 to 9 stages (+ `deploy-uptime` after
|
||||||
|
`publish-outputs`). The `deploy-uptime` stage constructs a synthetic uptime
|
||||||
|
contract from the L2 stack outputs, resolves + adapts it to a separate
|
||||||
|
terraform state directory, and publishes the uptime URL via PR comment.
|
||||||
|
|
||||||
|
### Forge-Agnostic API URLs
|
||||||
|
|
||||||
|
The platform Lambda (`contract_ingestor.py`) reads `GITHUB_API_BASE` env
|
||||||
|
for forge-agnostic API URLs. GitHub uses `/search/issues`; Gitea uses
|
||||||
|
`/repos/{owner}/{repo}/issues`. Detection via `/api/v1` in the base URL.
|
||||||
|
|
||||||
|
## v1.9 Addendum (2026-07-23)
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
|
||||||
|
- **`core/contract_resolver.py` interpolation** (D-081): the resolver
|
||||||
|
now expands `${env.<field>}` + `${contract.<field>}` tokens
|
||||||
|
post-schema-validation, pre-IR-resolution. The env context is the
|
||||||
|
loaded environment onboarding JSON (`core/environments/<name>.json`,
|
||||||
|
schema `schemas/environment.schema.json`). The resolver's
|
||||||
|
`child_input_map` routes L2 wires to the sub-resource that declares the
|
||||||
|
input (P1-1 — `desired_count` → `aws:ecs:service`, `family` →
|
||||||
|
`aws:ecs:task_definition`).
|
||||||
|
- **`core/environment_check.py` `load()`** (REQ-104): loads + returns the
|
||||||
|
parsed environment JSON; emits a stderr warning for placeholder
|
||||||
|
`account_id` when env != dev.
|
||||||
|
- **`core/hitl_gates.py`** (REQ-108, D-084): the HITL pre-execution
|
||||||
|
attestation gate. Records the approver identity to the DynamoDB outbox
|
||||||
|
(`approver_qa`/`approver_prod`/`approver_dr`), runs the separation-of-
|
||||||
|
duties check on prod, invokes the attestation matrix, returns
|
||||||
|
`(ok, reason)`. Dev skips (autonomous). `run_platform.sh` calls
|
||||||
|
`attest` before apply for qa/prod/dr.
|
||||||
|
- **`core/attestation_matrix.py`** (REQ-109, D-084): the 8-concern
|
||||||
|
attestation matrix from `hitl_matrix_design.md` §10.4. Offline-testable
|
||||||
|
concerns (contract NFRs, schema validity, policy pass) run for real;
|
||||||
|
operator-supplied concerns accept signed evidence artifacts validated
|
||||||
|
for freshness + schema. Signature verification skips when
|
||||||
|
`ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (D-089).
|
||||||
|
- **`core/separation_of_duties.py` `route_halt_artifact`** (REQ-107):
|
||||||
|
real SNS publish (`acdl-sod-halt` topic, ARN from
|
||||||
|
`ACDL_SOD_HALT_TOPIC_ARN`) + outbox fallback
|
||||||
|
(`SEPARATION_OF_DUTIES_VIOLATION` event). The SNS topic is defined in
|
||||||
|
`terraform/platform/main.tf`.
|
||||||
|
- **`adapters/wiz/wiz_adapter.py` `WizClient`** (REQ-110): real GraphQL
|
||||||
|
API client (`<WIZ_API_URL>/graphql`, Bearer auth, pagination via
|
||||||
|
`pageInfo.hasNextPage`). `fetch_and_adapt` translates issues →
|
||||||
|
`PolicyCheckResult`. Graceful degrade when unconfigured.
|
||||||
|
- **`adapters/kyverno/kyverno_adapter.py`** (REQ-111): fleshed-out
|
||||||
|
`PolicyReport` → `PolicyCheckResult` mapping (pass/fail/skip/warn +
|
||||||
|
severity + skip-with-reason + resource construction). Inactive-for-TF
|
||||||
|
guard preserved.
|
||||||
|
|
||||||
|
### Per-Environment Promotion (D-082)
|
||||||
|
|
||||||
|
The deploy workflow (`.github/workflows/deploy.yml` +
|
||||||
|
`.gitea/workflows/deploy.yml`, byte-identical) declares an `environment`
|
||||||
|
`workflow_call` input. When non-empty, `run_platform.sh --environment
|
||||||
|
<name>` overrides the contract's `environment` field before schema
|
||||||
|
validation (D-088). One CI job per environment; promotion = running the
|
||||||
|
matching job, no `environment:` field editing. Per-env contract files
|
||||||
|
(`contracts/<module>.<env>.yaml`) use interpolation for env-specific
|
||||||
|
values.
|
||||||
|
|
||||||
|
### Adapter Parameterization (P1-1, D-085)
|
||||||
|
|
||||||
|
The adapter (`adapters/terraform/adapter.py`) reads ECS/ALB/VPC defaults
|
||||||
|
from L1 `interface.json` inputs (`desired_count`, `launch_type`,
|
||||||
|
`family`, `target_type`, `load_balancer_type`, `name`). The adapter is a
|
||||||
|
thin translator; the `child_input_map` routes wires to the declaring
|
||||||
|
sub-resource.
|
||||||
|
|
||||||
|
### Deferred (D-083)
|
||||||
|
|
||||||
|
S3 Object Lock + JWS detached signatures + async worker + DLQ + daily
|
||||||
|
checkpoints (audit ledger build-out) — deferred to a future milestone.
|
||||||
|
The hash-chain + DynamoDB-outbox path remains the v1.9 production audit
|
||||||
|
record.
|
||||||
|
|
||||||
|
## v1.10 Addendum — Regression VERIFY + Local Emulators + Capability Re-Verification
|
||||||
|
|
||||||
|
### Regression-Class VERIFY (D-091, `core/regression_verify.py`)
|
||||||
|
|
||||||
|
The standard VERIFY stage was diff-scoped (it checked the phase diff
|
||||||
|
only, never re-ran underlying capability). This let 8 NFR-patch phases
|
||||||
|
(v1.9.1–v1.9.8) pass while the platform decayed. The regression-class
|
||||||
|
VERIFY (`core/regression_verify.py`) re-runs capability checks against
|
||||||
|
the current codebase and tags each Verified/Decayed/Broken. It fails
|
||||||
|
closed on any non-Verified capability, blocking milestone completion.
|
||||||
|
|
||||||
|
The registry (`CAPABILITY_REGISTRY`) holds 16 capability checks
|
||||||
|
(CAP-001..CAP-016): 12 local-tier + 4 live-AWS. Adding a capability is
|
||||||
|
a single function + one registry entry. The gate runs via
|
||||||
|
`scripts/run_regression.sh` and writes `.ciagent/REGRESSION_REPORT.md`
|
||||||
|
+ `.json`.
|
||||||
|
|
||||||
|
### Local Emulating Adapters (D-092, `core/local_emulators.py`)
|
||||||
|
|
||||||
|
Four local adapters let the platform run the full headline E2E without
|
||||||
|
cloud credentials:
|
||||||
|
|
||||||
|
- `FlatFileOutbox` — flat-file DynamoDB outbox emulator (hash-chained
|
||||||
|
JSONL; resumable across instances; chain verification).
|
||||||
|
- `LocalEcsEmulator` — local ECS Fargate HTTP 200 emulator (binds port
|
||||||
|
0 on 127.0.0.1; daemon thread; clean destroy).
|
||||||
|
- `LocalS3StateBackend` — rewrites the terraform S3 backend to a local
|
||||||
|
backend (per-stack tfstate in a temp folder).
|
||||||
|
- `LocalLambdaStub` — invokes the contract_ingestor handler in-process
|
||||||
|
(patches `_get_dynamodb`/`_get_secrets_client`/`urllib.urlopen`;
|
||||||
|
DynamoDB writes redirected to the FlatFileOutbox).
|
||||||
|
|
||||||
|
`run_local_e2e()` runs the full pipeline: contract → resolver → adapter
|
||||||
|
→ local S3 backend → local ECS (HTTP 200) → flat-file outbox (chain
|
||||||
|
verified) → local Lambda (200). Gated on `ACDL_LOCAL_TIER=1`.
|
||||||
|
|
||||||
|
### Capability Re-Verification Sweep (D-093)
|
||||||
|
|
||||||
|
`.ciagent/CAPABILITY_INVENTORY.md` enumerates 16 auto-verified
|
||||||
|
capabilities + 6 IAM-gated escalated resources. The sweep found and
|
||||||
|
fixed 7 adapter defects in `adapters/terraform/adapter.py` (duplicate
|
||||||
|
outputs, duplicate args, missing required args, deprecated AWS provider
|
||||||
|
v5 arg names). The headline E2E now passes at both tiers: local
|
||||||
|
emulator + live-AWS terraform init/validate/plan.
|
||||||
|
|
||||||
|
### Adapter Defect Fixes (P54)
|
||||||
|
|
||||||
|
7 defects fixed in `adapters/terraform/adapter.py`:
|
||||||
|
1. Duplicate output definitions (per-resource + stack-level both emitted).
|
||||||
|
2. Duplicate `desired_count`/`launch_type` on ECS service.
|
||||||
|
3. Duplicate `target_type`/`family`/`load_balancer_type`.
|
||||||
|
4. Missing `assume_role_policy`/`role_name` on IAM role (L2 composition gap).
|
||||||
|
5. Missing `cidr_block`/`vpc_id`/`name` defaults on VPC/subnet/route_table/
|
||||||
|
ECS cluster/ECR repository.
|
||||||
|
6. ECR `kms_key_arn` unsupported arg → `encryption_configuration` block.
|
||||||
|
7. CloudFront OAC + WAF deprecated arg names (AWS provider v5):
|
||||||
|
`signing_behavior`, `signing_protocol`, `origin_access_control_id`,
|
||||||
|
`s3_origin_config.origin_access_identity`, `origin_id`, `rule`
|
||||||
|
(singular), `scope=CLOUDFRONT` (uppercase).
|
||||||
|
|
||||||
|
## v1.11 Addendum — Stateless Adapter + Pipeline-Driven Lifecycle Testing
|
||||||
|
|
||||||
|
**Stateless adapter (D-098).** `adapters/terraform/adapter.py` rewritten
|
||||||
|
from a 918-line monolith (3 constant tables `TYPE_MAP`/`INPUT_MAP`/
|
||||||
|
`OUTPUT_MAP`, 39 type-specific branches) to a ~80-line stateless assembler.
|
||||||
|
Each L1 module ships a real `terraform/` module dir
|
||||||
|
(`versions.tf`/`variables.tf`/`locals.tf`/`main.tf`/`outputs.tf`) owning
|
||||||
|
its resource shape, nested blocks, and defaults. The adapter reads the
|
||||||
|
registry, emits a root `main.tf` instantiating each L1 as
|
||||||
|
`module "x" { source = "..." }` with resolved inputs and wired refs.
|
||||||
|
|
||||||
|
**Terraform owns lifecycle (D-101).** `scripts/run_platform.sh` gains
|
||||||
|
`--apply` and `--destroy` modes. Python never runs terraform.
|
||||||
|
`scripts/verify_deploy_microservice.py` is deleted.
|
||||||
|
|
||||||
|
**Pipeline-driven testing (D-102).** A `modules-lifecycle` pipeline
|
||||||
|
(Gitea + GitHub, byte-identical) matrix-runs each L1 module's
|
||||||
|
`examples/{simple,complex}.yml` contracts through apply→modify→destroy
|
||||||
|
against live AWS. No per-module Python/pytest. The "test" = the pipeline
|
||||||
|
cell going green.
|
||||||
|
|
||||||
|
**Single platform VPC (D-105).** `terraform/platform/main.tf` owns ONE
|
||||||
|
VPC; the microservice composition references it via
|
||||||
|
`terraform_remote_state` (data source). State keys are deterministic and
|
||||||
|
env-aware (`spike/{contract.id}/{contract.environment}/terraform.tfstate`).
|
||||||
|
|
||||||
|
**NOVA_LIFECYCLE_MODE (v1.12, REQ-134; renamed ACDL→NOVA in v1.15 P2).** The lifecycle pipeline defaults
|
||||||
|
to plan-only (fast, no AWS mutation, no cost). A CI variable
|
||||||
|
`NOVA_LIFECYCLE_MODE` (default `plan`) overrides to `full` for the real
|
||||||
|
apply→modify→destroy. (P2–P4 dual-read fallback to `ACDL_LIFECYCLE_MODE`;
|
||||||
|
fallback removed in P5 per the v1.15 addendum.)
|
||||||
|
|
||||||
|
## v1.12 Addendum — Presentation Refinement + CAP-013 Fix
|
||||||
|
|
||||||
|
**CAP-013 adapter dedup fix (REQ-129).** Multi-resource L1s (ecs-service,
|
||||||
|
alb) with stack outputs + cross-module refs now dedup to ONE module block
|
||||||
|
named by the composition child id, with expanded sub-ids rewritten via
|
||||||
|
`id_remap`. `terraform validate` succeeds for the microservice stack.
|
||||||
|
|
||||||
|
**CAP-017/018 probe fixes (REQ-130).** CAP-017's probe no longer requires
|
||||||
|
`locals.tf` for modules that legitimately omit it. CAP-018's probe
|
||||||
|
instantiates `LocalLambdaStub` with the required `outbox` arg.
|
||||||
|
|
||||||
|
## v1.13 Addendum — Presentation Polish + Config Schema Migration
|
||||||
|
|
||||||
|
**Config.json schema migration (v1.13.1).** Regenerated
|
||||||
|
`.ciagent/config.json` to the updated CIAgent v2 config structure (drop
|
||||||
|
removed fields, migrate `gitea`→`release.gitea`, add
|
||||||
|
`secrets`/`ship`/`backend`/`ideation`/`personas`/`logging`/`telemetry`
|
||||||
|
sections).
|
||||||
|
|
||||||
|
**Presentation polish (v1.13.0, v1.13.2).** Action headlines, story-arc
|
||||||
|
restructure, larger fonts, 6 new mermaid diagrams, badge cleanup,
|
||||||
|
platform-architecture diagram. Docs-only NFR patches.
|
||||||
|
|
||||||
|
## v1.14 Addendum — NFR Refinement (bug fixes, security, stubs, tests, docs)
|
||||||
|
|
||||||
|
**Bug fixes (Wave 1, P1-P6).** Adapter dedup rejects unregistered modules
|
||||||
|
with ValueError (P1). Static-assets composition wires cloudfront inputs
|
||||||
|
(P2). L2 lifecycle scripts document remote-state design (P3). Regression
|
||||||
|
gate adds `terraform fmt -check` syntax probe (P4). Adapter dedup-merge +
|
||||||
|
remote-state-key unit tests (P5). ALB target group name_prefix derives
|
||||||
|
from var.name (P6).
|
||||||
|
|
||||||
|
**Security (Wave 2, P7-P12).** 6 swallowed-error sites narrowed to
|
||||||
|
specific exceptions (P7). Account ID externalized to
|
||||||
|
`ACDL_AWS_ACCOUNT_ID` env (P8). IAM policy scoped to `acdl-*` ARNs (P9).
|
||||||
|
Contract ingestor validates contractId/environment/error (P10). Environment
|
||||||
|
schema adds `additionalProperties: false` + format validation (P11).
|
||||||
|
`.gitignore` credential-pattern catch-all (P12).
|
||||||
|
|
||||||
|
**Stub/test/CI/hygiene (Wave 3, P13-P17).** Kyverno `--kube-version` flag
|
||||||
|
removed (P13, G-103). Orphan artifacts + dead config cleaned (P14). 7
|
||||||
|
untested scripts gain test coverage (P15). Gitea workflow parity
|
||||||
|
documented + script `set` flags fixed (P16). Config.json persona +
|
||||||
|
branching strategy + ollama-cloud aligned (P17).
|
||||||
|
|
||||||
|
**Standards/docs/VPC (Wave 4, P18-P20).** STANDARDS.md reconciled (P18).
|
||||||
|
Documentation synced: ARCHITECTURE.md addenda, stale `@v1.6-1.9` → `@v1.13`,
|
||||||
|
GRILL G-005/G-008 resolved, COST.md window extended, D-083 deferral
|
||||||
|
recorded (P19). Platform VPC CIDR parameterized + data-driven subnet
|
||||||
|
count (P20).
|
||||||
|
|
||||||
|
**D-083 deferral (explicit).** The audit ledger build-out (S3 Object Lock
|
||||||
|
+ JWS detached signatures + SQS DLQ + async worker + daily checkpoints)
|
||||||
|
remains deferred (D-096, v1.14). The hash-chain + DynamoDB outbox is the
|
||||||
|
v1.14 audit record. JWS per-event authenticity is not implemented; a
|
||||||
|
forged event is only detectable by re-reading the whole chain. The
|
||||||
|
deferral is documented here explicitly per the v1.14 grill (E-001).
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.15 Addendum — Nova Rebrand (Major/breaking, 2026-07-30)
|
||||||
|
|
||||||
|
**Milestone:** v1.15-Nova. A full rebrand from **ACDL** / "Agentic Cloud
|
||||||
|
Delivery Platform" → **Nova** / "The New Dawn of DevSecOps — security
|
||||||
|
as a seamless enabler of fast deployments." This is a **Major
|
||||||
|
milestone** (breaking): consumer-facing path, env var prefixes, SSM
|
||||||
|
path, AWS tag keys, and AWS resource names all change. Per the
|
||||||
|
branch-strategy precedent (breaking/feature milestones tag on their
|
||||||
|
OWN minor line), v1.15 tags run on the **v1.15.x minor line**:
|
||||||
|
`v1.15.0` (P0) → `v1.15.4` (P5 final = release). (G-104 binding.)
|
||||||
|
|
||||||
|
### Naming conventions (rebranded)
|
||||||
|
|
||||||
|
| Convention | Before (v1.0–v1.14) | After (v1.15+) | Phase |
|
||||||
|
|------------|---------------------|-----------------|-------|
|
||||||
|
| Project name | `ACDL` / "Agentic Cloud Delivery Platform" | `Nova` / "The New Dawn of DevSecOps" | P1 |
|
||||||
|
| Tagline | "Consumers declare intent; the platform delivers safe production deployment through an agentic stack" | (retained) **+** "The New Dawn of DevSecOps — security as a seamless enabler of fast deployments" | P1 |
|
||||||
|
| Schema `$id` URL | `https://acdl.cloudinit.dev/schemas/...` | `https://nova.cloudinit.dev/schemas/...` | P1 |
|
||||||
|
| Gitea release title | `ACDL vX.Y.Z` | `Nova vX.Y.Z` | P1 (forward only) |
|
||||||
|
| Env var prefix | `ACDL_*` (21 vars) | `NOVA_*` (dual-read fallback in P2–P4; removed P5) | P2 |
|
||||||
|
| Env loader | scattered `os.environ.get("ACDL_*")` | centralized `core/env.py` `get_env()` (D-108) | P2 |
|
||||||
|
| Consumer contract path | `.acdl/contract.yml` | `.nova/contract.yml` | P2 |
|
||||||
|
| Checkov custom rule file | `acdl_tagging.py` | `nova_tagging.py` | P2 |
|
||||||
|
| Checkov tag-key enforcement | `acdl:*` (hard) | `nova:*` (warn P2, hard P3) | P2/P3 |
|
||||||
|
| SSM parameter path | `/acdl/{env}/{contractId}/{output}` | `/nova/{env}/{contractId}/{output}` | P3 |
|
||||||
|
| AWS tag keys | `acdl:owner|environment|contract|cost-center|ref` | `nova:owner|environment|contract|cost-center|ref` | P3 |
|
||||||
|
| ABAC session policy match | `acdl:*` tags | `nova:*` tags (parallel-tag period) | P3 |
|
||||||
|
| DynamoDB tables | `acdl-contracts`, `acdl-change-requests` | `nova-contracts`, `nova-change-requests` (scan+copy) | P4 |
|
||||||
|
| Lambda (ingestor) | `acdl-contract-ingestor` (role/policy/function) | `nova-contract-ingestor` | P4 |
|
||||||
|
| Secrets Manager secret | `acdl/github-token` | `nova/github-token` | P4 |
|
||||||
|
| SNS topic | `acdl-sod-halt` | `nova-sod-halt` | P4 |
|
||||||
|
| Security group | `acdl-ecs-sg` | `nova-ecs-sg` | P4 |
|
||||||
|
| KMS alias | `alias/acdl-platform` | `alias/nova-platform` | P4 |
|
||||||
|
| ECS cluster/service/task | `acdl-microservice` | `nova-microservice` | P4 |
|
||||||
|
| ECR repo | `acdl-microservice` | `nova-microservice` (re-push) | P4 |
|
||||||
|
| IAM user/policy | `acdl-spike-runner` (+policy) | `nova-spike-runner` (re-bootstrap) | P4 |
|
||||||
|
| S3 state bucket | `acdl-tfstate-581513795199-us-east-1` | `nova-tfstate-581513795199-us-east-1` (`-migrate-state`) | P4 |
|
||||||
|
| ALB name prefix | `acdl-alb` | `nova-alb` | P4 |
|
||||||
|
| Lambda default table names | `CONTRACTS_TABLE` default `acdl-contracts` | default `nova-contracts` (D-111) | P4 |
|
||||||
|
|
||||||
|
### Unchanged conventions (out of scope)
|
||||||
|
|
||||||
|
- **S&P Global Energy visual theme** (`sp-theme.json`, deck CSS: #D6002A
|
||||||
|
red, Akkurat Pro) — client branding, not the Nova product brand (D-107).
|
||||||
|
- **config.json `release.gitea.repo`** = `acdl` — real Gitea repo name
|
||||||
|
unchanged (D-105). Doc URLs updated to `nova` for prose only.
|
||||||
|
- **Git branch/tag naming** — `milestone/v*`, `phase/*`, `v*` semver; no
|
||||||
|
brand name present (D-112: flat-branch convention preserved).
|
||||||
|
- **Past Gitea release titles** — existing releases keep `ACDL vX.Y.Z`.
|
||||||
|
|
||||||
|
### Migration ordering (binding)
|
||||||
|
|
||||||
|
1. **P1** docs/decks/prose — no runtime impact; ships consumer migration
|
||||||
|
guide announcing the 5 breaking changes.
|
||||||
|
2. **P2** code + env vars (dual-read) + consumer path — deployments don't
|
||||||
|
break during the transition window (dual-read fallback).
|
||||||
|
3. **P3** SSM path (copy → read → delete) + tag keys (parallel-tag →
|
||||||
|
policy swap → remove old).
|
||||||
|
4. **P4** AWS resource names — staged terraform migration (KMS alias,
|
||||||
|
SNS/SG/Lambda recreate, DynamoDB scan+copy, ECR re-push, IAM
|
||||||
|
re-bootstrap, state bucket `-migrate-state`, ALB recreate). Maintenance
|
||||||
|
window + rollback runbook (`docs/NOVA_AWS_MIGRATION.md`).
|
||||||
|
5. **P5** final review + audit + remove dual-read fallback + milestone ship.
|
||||||
|
|
||||||
|
### Capability gate (binding)
|
||||||
|
|
||||||
|
The regression gate (CAP-001..CAP-016, `scripts/run_regression.sh`) must
|
||||||
|
stay **16/16 Verified** throughout the rebrand. P2/P3/P4 update test
|
||||||
|
fixtures that reference `ACDL`/`acdl` so the gate stays green. No
|
||||||
|
capability is added, removed, or reclassified in v1.15 — the rebrand is
|
||||||
|
nomenclature + identifiers, not behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.16 Addendum — Nova Simplification (NFR, 2026-07-30)
|
||||||
|
|
||||||
|
The v1.16 NFR milestone added 6 new code components + 1 new Terraform
|
||||||
|
module + 1 new schema, all documented here for the architecture record.
|
||||||
|
|
||||||
|
### New components
|
||||||
|
|
||||||
|
| Component | Path | Purpose |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Onboarding request handler | `core/onboarding.py` | `generate_env_file(request, template_env)` — produces a `<env>.json` from a consumer onboarding request (P19, REQ-183). CLI entry point for self-service env-file generation. |
|
||||||
|
| Decommission transform | `core/decommission_transform.py` | `decommission_transform(stack)` — zero counts + disable deletion protection (REQ-92). Extracted from contract_resolver (P12, REQ-176). |
|
||||||
|
| Contract resolver CLI | `core/contract_resolver_cli.py` | `main()` CLI entry point — resolves a contract YAML to a Target Stack JSON. Extracted from contract_resolver (P12, REQ-176). |
|
||||||
|
| Regression verify CLI | `core/regression_verify_cli.py` | `main()` CLI entry point — runs the regression gate + writes the report. Extracted from regression_verify (P13, REQ-177). |
|
||||||
|
| Workflow sync generator | `scripts/sync_workflows.py` | `--check`/`--write` — generates the 3 byte-identical Gitea+GitHub workflow pairs from `workflows-src/` (P8, REQ-172). |
|
||||||
|
| Onboarding Terraform | `terraform/onboarding/` | `aws_iam_role.consumer_deploy` + `aws_iam_role_policy.consumer_invoke` (ABAC `nova:owner` tag). Offline-proven only (P20, REQ-184, D-114). |
|
||||||
|
|
||||||
|
### Modified components
|
||||||
|
|
||||||
|
| Component | Change | Phase |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `core/contract_resolver.py` | `_load_env` delegates to `environment_check.load()` (dedup); `is_l2` uses registry `kind` field; `_load_schema` caches schemas; `decommission_transform` + CLI re-export shim (P12). | P7, P12, P14 |
|
||||||
|
| `core/regression_verify.py` | Dedup helpers (`_check_resolver`, `_check_live_terraform_plan`, `_assert_contracts_resolve`); CAP-013..016 `Skipped` on post-teardown (G-111); `passed` accepts Skipped; CLI re-export shim (P13). | P5, P9, P13 |
|
||||||
|
| `core/lambda/contract_ingestor.py` | Fail closed on missing IAM identity (P10); env enum from `core/environments/` (P10); payload size cap + schema validation (P11); `onboard_consumer` action (P18); `[NOVA-ALERT]` rebrand (P2). | P2, P10, P11, P18 |
|
||||||
|
| `core/output_publisher.py` | `SAFE_OUTPUT_NAMES` schema-driven from `interface.json`; narrowed excepts; `urllib.error` import (P4, P14). | P4, P14 |
|
||||||
|
| `core/environment_check.py` | Onboarding message rebranded Nova + self-service request path (P2, P19). | P2, P19 |
|
||||||
|
| `core/local_emulators.py` | `LocalLambdaStub` sets `NOVA_LAMBDA_LOCAL_BYPASS`; stale dual-read comments + `acdl_*` prefixes removed (P3, P10). | P3, P10 |
|
||||||
|
| `scripts/run_platform.sh` | `--help` flag; `run_hitl_gate()` fn; `NOVA_CONTRACT_ID`/`NOVA_WORK_DIR` config; decommission + uptime blocks extracted to sourced helpers (P6, P9, P15). | P6, P9, P15 |
|
||||||
|
| `adapters/terraform/adapter.py` | State bucket `nova-tfstate-*` (P1); module docstring Nova (P2). | P1, P2 |
|
||||||
|
| `adapters/kyverno/policies/require-resource-labels.yml` | `nova:*` labels (not `acdl:*`) (P1). | P1 |
|
||||||
|
| `modules/registry.json` | `kind` field (`l1`/`l2`) on all 14 entries (P7). | P7 |
|
||||||
|
|
||||||
|
### New schema
|
||||||
|
|
||||||
|
- `schemas/onboarding.schema.json` — the self-service onboarding request
|
||||||
|
(consumerRepo, requestedEnvironment, ownerId, billingTag). P18, REQ-182.
|
||||||
|
|
||||||
|
### Onboarding request-path architecture (D-113)
|
||||||
|
|
||||||
|
The no-humans onboarding flow is a 3-step request path (real AWS
|
||||||
|
provisioning deferred):
|
||||||
|
|
||||||
|
```
|
||||||
|
Consumer → POST Lambda (onboard_consumer) → pending CMDB row (P18)
|
||||||
|
→ core/onboarding.py → <env>.json binding file (P19)
|
||||||
|
→ terraform/onboarding/ → cross-account role + ABAC tag (P20, offline)
|
||||||
|
```
|
||||||
|
|
||||||
|
The Lambda Function URL (IAM auth) + `consumer_invoke_policy.json` (ABAC
|
||||||
|
`nova:owner`) are the transport; the request is accepted + a binding
|
||||||
|
generated + the role Terraform proven offline. No AWS resources are
|
||||||
|
created by the request path (D-113/D-114).
|
||||||
|
|
||||||
|
### Regression gate (G-111 binding)
|
||||||
|
|
||||||
|
The regression gate (D-091) now treats `Skipped` as acceptable for the
|
||||||
|
post-v1.11-teardown steady state (D-096): CAP-013..016 (live-AWS tier)
|
||||||
|
return `Skipped` when the resources are absent (`NoSuchBucket`/
|
||||||
|
`ResourceNotFoundException`). `RegressionReport.passed` is
|
||||||
|
`all(r.status in ("Verified", "Skipped"))`. The gate passes at 18
|
||||||
|
Verified + 4 Skipped (0 Decayed/Broken).
|
||||||
|
|
||||||
|
## v1.17 Addendum — Strategic Direction, Leadership Metrics & Unified Story (2026-08-04)
|
||||||
|
|
||||||
|
The v1.17 milestone adds a telemetry/observability layer, a Decision
|
||||||
|
Ledger, a metrics export pipeline, a unified narrative deck, and a
|
||||||
|
durable strategic-direction artifact. This addendum documents the
|
||||||
|
architecture; the full research findings are in RESEARCH.md §v1.17.
|
||||||
|
|
||||||
|
### New components
|
||||||
|
|
||||||
|
| Component | Path | Purpose |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Event envelope | `core/metrics/event_envelope.py` | CloudEvents 1.0 envelope + `platform.*` semantic conventions (P1, REQ-187) |
|
||||||
|
| Per-run manifest writer | `core/metrics/run_manifest.py` | Emits `nova.run.started/completed/failed` events + writes `metrics/runs/<run_id>.json` (P1, REQ-187) |
|
||||||
|
| Decision Ledger (SQLite) | `core/metrics/decision_ledger.py` | Extends `outbox_writer.py` → SQLite append-only hash-chain table; `ai.decision.made` + `attestation.recorded` events + outcome backfill (P1, REQ-188, D-121) |
|
||||||
|
| Infracost post-processor | `core/metrics/infracost_adapter.py` | Runs Infracost on plan JSON; emits `nova.cost.estimated{delta_usd}` (P1, REQ-187, D-120) |
|
||||||
|
| Metrics collector | `core/metrics/collector.py` | Reads all grounded signals (files + events) → SQLite cold store at `metrics/nova_metrics.db` (P2, REQ-189) |
|
||||||
|
| PowerBI export | `core/metrics/powerbi_export.py` | Emits CSV/JSON views to `metrics/powerbi/` (fact + dim + 8 deferred placeholder views) (P3, REQ-190) |
|
||||||
|
| Metrics schemas | `schemas/metrics_*.schema.json` | Schemas for all event types + fact/dim tables (P1–P2, REQ-187/189) |
|
||||||
|
| Metrics catalog | `docs/METRICS.md` + `docs/metrics/<kpi>.md` | Canonical catalog + per-KPI definition-of-success docs (P4, REQ-195, D-127) |
|
||||||
|
| Unified narrative deck | `docs/presentations/nova-no-humans-platform.md` | Merged deck: Problem→Vision→How→Proof→Roadmap; x3 arc at deck+slide level (P5, REQ-196/197, D-130) |
|
||||||
|
| Strategic direction | `.ciagent/NORTH_STAR.md` | PO-authored durable vision/objectives/anti-goals/targets; read by CIAgent in every future `/ci-run` (P0, REQ-185/186) |
|
||||||
|
|
||||||
|
### Modified components
|
||||||
|
|
||||||
|
| Component | Change | Phase |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `core/outbox_writer.py` | Extended to emit to SQLite append-only hash-chain table (Decision Ledger); `ai.decision.made` + `attestation.recorded` events added (P1, D-121) | P1 |
|
||||||
|
| `scripts/run_platform.sh` | Per-run manifest writer invoked; `$WORK/*.json` persisted to `metrics/runs/`; Infracost post-processor invoked after plan (P1) | P1 |
|
||||||
|
| `core/hitl_gates.py` | Emits `attestation.recorded` event to Decision Ledger on qa/prod/dr gate (P1, D-132) | P1 |
|
||||||
|
| `core/confidence_signal.py` | Emits `nova.confidence.computed` + `nova.ai.decision.made` events (P1, D-122) | P1 |
|
||||||
|
| `adapters/terraform/policy/checkov_adapter.py` | Emits `nova.policy.evaluated` event (P1) | P1 |
|
||||||
|
| `core/regression_verify.py` | Emits `nova.capability.verified` event; CAP-023 (metrics collector) + CAP-024 (deck structure) added (P1, P6) | P1, P6 |
|
||||||
|
| `pyproject.toml` | `addopts` gains `--junitxml=metrics/test-results.xml` + `--json-report` (P1, D-120) | P1 |
|
||||||
|
| `docs/presentations/` | Two old decks retired (deleted); unified deck added (P5, D-130) | P5 |
|
||||||
|
|
||||||
|
### Telemetry/observability layer architecture (D-120)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nova platform components (existing) │
|
||||||
|
│ run_platform.sh · confidence_signal · checkov_adapter · │
|
||||||
|
│ hitl_gates · regression_verify · outbox_writer · contract_ingestor │
|
||||||
|
└──────────────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ CloudEvents 1.0 envelope (new emitters, P1)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ metrics/events.jsonl (append-only CloudEvents log) │
|
||||||
|
│ metrics/runs/<run_id>.json (per-run manifests) │
|
||||||
|
│ metrics/decision_ledger.db (SQLite hash-chain, D-121) │
|
||||||
|
│ metrics/test-results.xml (junit, P1) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ collector reads (P2)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ metrics/nova_metrics.db (SQLite cold store, D-126) │
|
||||||
|
│ fact_run · fact_capability · fact_policy_check · fact_confidence │
|
||||||
|
│ fact_test · fact_decision · fact_cost_estimate │
|
||||||
|
│ dim_capability · dim_milestone │
|
||||||
|
│ + 8 empty placeholder views (deferred metrics) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ powerbi_export (P3)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ metrics/powerbi/ (CSV/JSON views, folder connector, D-129) │
|
||||||
|
│ → PowerBI dashboards (external) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hot path: deferred (D-126).** No live ops dashboard; SQLite is
|
||||||
|
cold-only (batch/historical). The hot path activates when live AWS is
|
||||||
|
re-provisioned (D-096 lift).
|
||||||
|
|
||||||
|
### NORTH_STAR integration point (REQ-186)
|
||||||
|
|
||||||
|
`.ciagent/NORTH_STAR.md` is read by CIAgent in context-loading for all
|
||||||
|
future milestones. The integration mechanism (to be finalized in P4):
|
||||||
|
a reference from `PROJECT.md` + `ARCHITECTURE.md` (this section) + a
|
||||||
|
config entry in `config.json` (`strategic_direction_file:
|
||||||
|
".ciagent/NORTH_STAR.md"`) that the run workflow reads at SPECIFY. This
|
||||||
|
ensures the strategic direction survives across milestones without
|
||||||
|
being overwritten by status updates.
|
||||||
|
|
||||||
|
### §12.7 — Policy Engine Registry (v1.25, REQ-291)
|
||||||
|
|
||||||
|
The policy-engine abstraction is first-class: a swappable `PolicyEngine`
|
||||||
|
protocol so the engine may change without touching the confidence
|
||||||
|
signal, the pipeline, or the `PolicyCheckResult` schema. This is the
|
||||||
|
**swap boundary** that keeps the platform's compliance posture
|
||||||
|
replaceable (Strategic Objective #2 — provable trust via a replaceable
|
||||||
|
substrate, not a vendor lock-in).
|
||||||
|
|
||||||
|
```
|
||||||
|
contract.yml ─┐ ┌─→ list[PolicyCheckResult] ─┐
|
||||||
|
stack IR ─────┼─→ PolicyEngine.evaluate ├─→ list[PolicyCheckResult] ─┼─→ confidence_signal
|
||||||
|
plan JSON ────┤ (protocol) └─→ list[PolicyCheckResult] ─┘ (engine-agnostic,
|
||||||
|
PCR list ─────┘ unchanged)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─ KyvernoJsonEngine (shells to `kj scan`; engine: "kyverno")
|
||||||
|
└─ OpaEngine (future — same protocol; engine: "opa")
|
||||||
|
|
||||||
|
checkov/wiz ──→ raw findings ──→ (merged PCR list is the meta-policy payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The protocol (`core/policy_engine.py`):**
|
||||||
|
```python
|
||||||
|
class PolicyEngine(Protocol):
|
||||||
|
@property
|
||||||
|
def name(self) -> str: ...
|
||||||
|
def is_configured(self) -> bool: ...
|
||||||
|
def evaluate(self, payload, policy_dir: Path, contract_id: str) -> list[dict]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**The registry** reads `config.json.policy.engine` (default
|
||||||
|
`"kyverno-json"`) and returns the active engine. A `NullEngine` is the
|
||||||
|
fallback when the `policy` key is absent (emits `SKIPPED` PCRs —
|
||||||
|
backward compatibility for tests that don't set the key). The
|
||||||
|
confidence signal is **untouched** — it already consumes
|
||||||
|
`list[PolicyCheckResult]` engine-agnostically (§12.6). v1.25 only
|
||||||
|
changes *who produces* the PCR list, not *what* the list is.
|
||||||
|
|
||||||
|
**Engine enum reuse (D-116):** kyverno-json PCR records carry
|
||||||
|
`engine: "kyverno"` (no new enum value). The `engine` field records the
|
||||||
|
policy-engine *family*, not the specific binary. The K8s Kyverno adapter
|
||||||
|
and the kyverno-json engine are distinguished by `ruleId` prefix
|
||||||
|
(`KYVERNO_` vs `KJ_`) and `evidence` payload shape (`namespace`/`kind`
|
||||||
|
vs `assertion`/`jmespath`).
|
||||||
|
|
||||||
|
**Defense-in-depth (D-119):** the declarative meta-policy
|
||||||
|
`block-on-any-critical` (asserts no PCR has `severity: critical` +
|
||||||
|
`result: fail`) is the *source of truth* for "critical = block". The
|
||||||
|
`confidence_signal.py` `PENALTY["critical"]: None` hard-override stays
|
||||||
|
as the *imperative* safety net — the meta-policy runs *before* the
|
||||||
|
confidence signal (produces PCRs that flow in), the hard-override runs
|
||||||
|
*inside* it (the last gate). Removing the hard-override would make the
|
||||||
|
"critical = block" guarantee depend on a single policy file — a
|
||||||
|
regression in provable trust.
|
||||||
|
|
||||||
|
**Graceful degradation (D-120):** `KyvernoJsonEngine.is_configured()`
|
||||||
|
returns false when `which kj` is absent → `evaluate()` returns a single
|
||||||
|
`SKIPPED` PCR (`ruleId: "KJ_ENGINE_NOT_CONFIGURED"`). The platform
|
||||||
|
functions without the binary (the "platform functions without AI /
|
||||||
|
deterministic scripts" tenet holds — kyverno-json is deterministic, not
|
||||||
|
AI; the `is_configured()` guard ensures the platform runs even when the
|
||||||
|
binary is not installed).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# P4 — Live Pilot Run Evidence (v1.26, v0.2 re-run)
|
||||||
|
|
||||||
|
> The live `terraform apply` against AWS `581513795199` succeeded. The
|
||||||
|
> Decision Ledger + outcome backfill are complete. SPEC §5.8 evidence
|
||||||
|
> stream verified.
|
||||||
|
|
||||||
|
## Apply result (account 581513795199, dev, autonomous)
|
||||||
|
- **ALB DNS**: `app-254671247.us-east-1.elb.amazonaws.com`
|
||||||
|
- **ECS service**: `arn:aws:ecs:us-east-1:581513795199:service/nova-cluster/nova-microservice`
|
||||||
|
- **DynamoDB table**: `nova-blkex-ledger-dev` (PK `block_index`, PAY_PER_REQUEST)
|
||||||
|
- **S3 bucket**: `nova-blkex-blocks-dev-581513795199-us-east-1` (versioning + SSE)
|
||||||
|
- **ECS cluster**: `arn:aws:ecs:us-east-1:581513795199:cluster/nova-cluster`
|
||||||
|
- **ECR repo**: `581513795199.dkr.ecr.us-east-1.amazonaws.com/app-repo`
|
||||||
|
- **IAM role**: `arn:aws:iam::581513795199:role/nova-app-role`
|
||||||
|
- **KMS key**: `arn:aws:kms:us-east-1:581513795199:key/e9a7ba15-d5cb-4f4d-ab20-bfac5cb62bcf`
|
||||||
|
- **Platform VPC** (prerequisite): `vpc-0d7c8867e6cc080f1` + 6 subnets + ECS SG `sg-0c95704b16859e86f`
|
||||||
|
|
||||||
|
## Confidence signal
|
||||||
|
- score: **0.800**, band: **pass** (dev autonomous, ≥0.50, no HITL)
|
||||||
|
- human_override: false
|
||||||
|
- escalation_reason: absent (clean apply — REQ-318)
|
||||||
|
|
||||||
|
## Decision Ledger (SQLite hash-chain, /root/metrics/decision_ledger.db)
|
||||||
|
- `nova.ai.decision.made` — decision_id `blkex-pilot-apply-v0.2`, chosen_action `pass`, human_override false
|
||||||
|
- `nova.outcome.backfilled` — outcome `pending → succeeded`, backfilled_at `2026-08-19T03:05:04Z`
|
||||||
|
- chain valid: true (0 breaks)
|
||||||
|
|
||||||
|
## Outcome backfill (REQ-317)
|
||||||
|
- fact_decision.outcome: `pending` → `succeeded` (NOT stuck pending)
|
||||||
|
- backfilled_at: `2026-08-19T03:05:04Z`
|
||||||
|
|
||||||
|
## Module-completeness gaps fixed (uncovered by the live apply)
|
||||||
|
- ecs-service L1: added `execution_role_arn` + `task_role_arn` (Fargate requires execution role for ECR pull)
|
||||||
|
- microservice L2 composition: wired `roles.outputs.role_arn` → `service.inputs.{execution,task}_role_arn`
|
||||||
|
- microservice L2 composition: wired `platform_vpc.outputs.ecs_security_group_id` → `alb.inputs.security_group` (ALB requires a SG)
|
||||||
|
|
||||||
|
## Run id
|
||||||
|
- NOVA_RUN_ID: `blkex-pilot-apply-v0.2`
|
||||||
|
|
||||||
|
---ci---
|
||||||
|
project: acdl
|
||||||
|
phase: 4
|
||||||
|
milestone: v1.26
|
||||||
|
status: execute
|
||||||
|
wave: W1
|
||||||
|
---
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
# `.ciagent/archive/` — Completed-Milestone History
|
||||||
|
|
||||||
|
This directory holds byte-identical snapshots of `.ciagent/` files that
|
||||||
|
were compressed out of the active agent context. Compression is **lossless
|
||||||
|
via relocation**: every original byte is reachable here, and the git
|
||||||
|
history at the commit prior to compression preserves the authoritative
|
||||||
|
state for offline agent loading.
|
||||||
|
|
||||||
|
## Why archive
|
||||||
|
|
||||||
|
The active milestone is v1.27 (PO State Catalog & Ciagent Compression).
|
||||||
|
The `.ciagent/` root was compressed twice:
|
||||||
|
|
||||||
|
1. **v1.26 P2 compression** (~11,164 lines → ~5,232): the
|
||||||
|
completed-milestone narratives (v1.0–v1.24) were relocated. Per the
|
||||||
|
run.md context-loading model, agents read `.ciagent/` every
|
||||||
|
`/ci-run`; the historical narrative was not load-bearing for v1.26
|
||||||
|
execution and was relocated to keep the working context lean.
|
||||||
|
2. **v1.27 P1 compression** (~5,232 → ~3,882): the v1.26 phase
|
||||||
|
verifications + review + evidence + the dated CAPABILITY_INVENTORY
|
||||||
|
(superseded by STATE.md) + AUTONOMY_THESIS (folded into NORTH_STAR)
|
||||||
|
+ COST (predates v1.26 pilot) were relocated. The 4 pre-execution
|
||||||
|
files (CLARIFY/GRILL/IDEATE/RESEARCH) were rewritten by v1.27 P0
|
||||||
|
and stay active through v1.27.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
### Snapshots of slimmed files (full content before compression)
|
||||||
|
|
||||||
|
| File | Original (lines) | Replaces | Status at time of snapshot |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PROJECT-v1.0-v1.24.md` | 1784 | `.ciagent/PROJECT.md` | v1.0–v1.24 milestone-by-milestone narrative + active milestone v1.26 sections |
|
||||||
|
| `REQUIREMENTS-v1.0-v1.24.md` | 2490 | `.ciagent/REQUIREMENTS.md` | All requirements v1.0 (REQ-01) through v1.26 (REQ-322) |
|
||||||
|
| `ROADMAP-v1.0-v1.24.md` | 2341 | `.ciagent/ROADMAP.md` | All phase breakdowns v1.0 through v1.26 |
|
||||||
|
| `ARCHITECTURE-v1.0-v1.24.md` | 945 | `.ciagent/ARCHITECTURE.md` | Full architecture reference + historical "how we got here" narrative |
|
||||||
|
|
||||||
|
The slimmed in-place files retain: active milestone v1.26 context, the
|
||||||
|
v1.25 milestone (since v1.26 tags ride the v1.25.x line), the durable
|
||||||
|
vision/tenets/RACI/capability-status sections, and the current-state
|
||||||
|
architecture reference.
|
||||||
|
|
||||||
|
### Completed-phase artifacts (relocated verbatim)
|
||||||
|
|
||||||
|
| File | Original (lines) | Phase(s) documented |
|
||||||
|
|---|---|---|
|
||||||
|
| `REVIEW.md` | 111 | Multi-persona code review records from completed phases |
|
||||||
|
| `AUDIT.md` | 553 | Project health audit records (reconstruction tests, branch hygiene) |
|
||||||
|
| `VERIFY.md` | 86 | Per-phase verification records |
|
||||||
|
| `PRE_MORTEM.md` | 228 | Pre-mortem analyses for completed milestones |
|
||||||
|
|
||||||
|
### v1.27 compression — archived files (8 files, lossless `git mv`)
|
||||||
|
|
||||||
|
> The v1.27 NFR milestone (PO State Catalog & Ciagent Compression) archived
|
||||||
|
> 7 platform-root files + 1 consumer file. All are byte-identical
|
||||||
|
> relocations; git history at the pre-v1.27 commits preserves the
|
||||||
|
> authoritative state.
|
||||||
|
|
||||||
|
#### Snapshots of superseded durable references (3 files)
|
||||||
|
|
||||||
|
| File | Original (lines) | Superseded by | Status at time of snapshot |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CAPABILITY_INVENTORY-v1.10.md` | 120 | `.ciagent/STATE.md` (v1.27) | The 2026-07-27 re-verification sweep (v1.1→v1.8 capabilities). Predates v1.26 pilot (CAP-025 absent; blockchain capabilities absent). |
|
||||||
|
| `AUTONOMY_THESIS-v1.21.md` | 65 | `NORTH_STAR.md` Vision + Anti-Goals #2 | "Last refined: v1.21" — the autonomy-in-operations thesis, fully folded into NORTH_STAR.md. |
|
||||||
|
| `COST-v1.14.md` | 106 | (future cost milestone) | AWS cost report dated 2026-07-29, framed "v1.0 → v1.14". Predates v1.26 live pilot (ECS + ALB + DynamoDB + S3 costs not reflected). |
|
||||||
|
|
||||||
|
#### v1.26 phase verifications + review + evidence (4 files)
|
||||||
|
|
||||||
|
| File | Original (lines) | Phase(s) documented |
|
||||||
|
|---|---|---|
|
||||||
|
| `VERIFY-P03.md` | 39 | v1.26 P3 verification — PASS (shipped `v1.25.3`) |
|
||||||
|
| `VERIFY-P04.md` | 31 | v1.26 P4 verification — PASS (shipped `v1.25.4`) |
|
||||||
|
| `REVIEW-AUDIT-P05.md` | 218 | v1.26 P5 final review + audit — PROCEED (shipped `v1.25.5`; 0 P0 remain; audit CLEAN) |
|
||||||
|
| `P4-PILOT-RUN-EVIDENCE-v1.26.md` | 46 | v1.26 live apply evidence (`blkex-pilot-apply-v0.2`; confidence 0.800 pass; outcome backfilled; hash chain valid) |
|
||||||
|
|
||||||
|
#### Consumer subproject archive (1 file)
|
||||||
|
|
||||||
|
| File | Original (lines) | Phase(s) documented |
|
||||||
|
|---|---|---|
|
||||||
|
| `nova-blockchain-exchange/archive/ROADMAP-v1.26.md` | 57 | v1.26 consumer roadmap (P3/P4/P5 marked "planned" at archive time; v1.26 shipped `v1.25.5`). Phase narrative preserved in the platform `.ciagent/ROADMAP.md` §v1.26. |
|
||||||
|
|
||||||
|
#### v1.26 pre-execution artifacts (in git history, not archived to disk)
|
||||||
|
|
||||||
|
The v1.26 pre-execution files (CLARIFY, GRILL, IDEATE, RESEARCH) were
|
||||||
|
overwritten by the v1.27 P0 pre-execution cycle. The v1.26-era content
|
||||||
|
is preserved in git history at the pre-v1.27-P0 commits (search the
|
||||||
|
log for `docs(P00):` commits on the `milestone/v1.26-pilot-activation`
|
||||||
|
line). The v1.27 P0 versions stay active through v1.27; they archive at
|
||||||
|
v1.28 P1 if v1.28 happens. Decisions D-200..D-213 (v1.26) are folded
|
||||||
|
into `PROJECT.md` load-bearing decisions; D-214..D-225 (v1.27) live in
|
||||||
|
the active `CLARIFY.md`.
|
||||||
|
|
||||||
|
### Live operational files NOT archived
|
||||||
|
|
||||||
|
These files remain at their canonical `.ciagent/` paths because they are
|
||||||
|
read/write targets of live code paths and must not be relocated:
|
||||||
|
|
||||||
|
- `REGRESSION_REPORT.json` — written by `core/regression_verify.py:705`,
|
||||||
|
read by `core/metrics/collector.py:27` + `core/metrics/trust_snapshot.py:21`
|
||||||
|
+ `metrics/` views.
|
||||||
|
- `REGRESSION_REPORT.md` — written by `core/regression_verify.py:704`,
|
||||||
|
referenced by `scripts/run_regression.sh`.
|
||||||
|
- `CHECKPOINT.json` — the authoritative resume point for `/ci-run`.
|
||||||
|
- `config.json` — operational configuration (no historical content).
|
||||||
|
|
||||||
|
## How to load archived content
|
||||||
|
|
||||||
|
Agents that need completed-milestone history can read these files
|
||||||
|
directly (they live inside `.ciagent/`, so the path convention holds):
|
||||||
|
|
||||||
|
```
|
||||||
|
.ciagent/archive/PROJECT-v1.0-v1.24.md
|
||||||
|
.ciagent/archive/REQUIREMENTS-v1.0-v1.24.md
|
||||||
|
.ciagent/archive/ROADMAP-v1.0-v1.24.md
|
||||||
|
.ciagent/archive/ARCHITECTURE-v1.0-v1.24.md
|
||||||
|
.ciagent/archive/{REVIEW,AUDIT,VERIFY,PRE_MORTEM}.md
|
||||||
|
```
|
||||||
|
|
||||||
|
For the authoritative pre-compression state of any `.ciagent/` file,
|
||||||
|
use git history at the commit immediately preceding the compression
|
||||||
|
commit (search the log for `chore(P02): compress .ciagent/ files`).
|
||||||
|
|
||||||
|
## `completed-milestones/`
|
||||||
|
|
||||||
|
Reserved for future per-milestone summary files if a milestone's
|
||||||
|
narrative is too large for the slimmed in-place ROADMAP/PROJECT. Currently
|
||||||
|
empty; v1.0–v1.24 narrative is fully preserved in the four snapshot
|
||||||
|
files above.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
# P05 Final Review + Audit — v1.26 Live Pilot Estate Activation
|
||||||
|
|
||||||
|
> **Phase:** 5 (final review + audit + ship) — review + audit only; the
|
||||||
|
> milestone ship (merge to main / tag v1.25.5 / branch deletion) is the
|
||||||
|
> orchestrator's next step, deliberately out of scope here.
|
||||||
|
> **Branch:** `phase/05-final-review-ship`
|
||||||
|
> **Milestone:** `milestone/v1.26-pilot-activation`
|
||||||
|
> **Tags so far:** v1.25.0 (P0) → v1.25.1 (P1) → v1.25.2 (P2) →
|
||||||
|
> v1.25.3 (P3) → v1.25.4 (P4). P5 ships v1.25.5 (= the v1.26 release).
|
||||||
|
> **Date:** 2026-08-19
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Review (ciagent-review equivalent)
|
||||||
|
|
||||||
|
Multi-persona review across P1..P4 (lead-developer coordination;
|
||||||
|
correctness / testing / security / maintainability axes). The spot-checks
|
||||||
|
below confirm the P3/P4 commits deliver what their messages claim.
|
||||||
|
|
||||||
|
### Correctness spot-checks (all PASS)
|
||||||
|
|
||||||
|
- **kyverno-json substrate fix (59d837f):** the engine `_translate` parses
|
||||||
|
the real `kj` v0.0.3 bare-list output (not the v1.25-assumed
|
||||||
|
`{"results":[...]}` dict); `_materialize_yaml_policy_dir` mirrors `.json`
|
||||||
|
policies to `.yaml` twins (kj v0.0.3 ignores `.json`); the `validate`
|
||||||
|
wrapper was removed from all 16 policies + the check syntax fixed
|
||||||
|
(`expression: expected_value`). All 36 kj-dependent tests pass against
|
||||||
|
real `kj` (0 skips). The install script fixed
|
||||||
|
(`go install .../kyverno-json@latest` + symlink, not the broken
|
||||||
|
`cmd/kj@latest`).
|
||||||
|
- **outcome backfill (51b886f, REQ-317):** `core/metrics/outcome_backfill.py`
|
||||||
|
updates `fact_decision.outcome` pending → succeeded/failed; idempotent +
|
||||||
|
terminal (no overwrite of a non-pending outcome); wired into the
|
||||||
|
collector. The P4 run evidence (6ced8ed) confirms
|
||||||
|
`nova.outcome.backfilled (pending->succeeded)`.
|
||||||
|
- **Gitea adapter (P3 W0):** the consumer `deploy.yml` has no cross-repo
|
||||||
|
`uses:` — inline `actions/checkout@v4` of `acdl/acdl @ ref: v1.25` into
|
||||||
|
`platform/` then `bash platform/scripts/run_platform.sh`. SPEC §10 Q1
|
||||||
|
resolved by evidence.
|
||||||
|
- **env-JSON state_backend (3300ed2, REQ-319):** the adapter reads
|
||||||
|
`env.state_backend.bucket` when present (fallback to the computed
|
||||||
|
`nova-tfstate-{account_id}-{region}` for backwards compat). `dev.json`
|
||||||
|
bound to `581513795199` + `nova-tfstate-581513795199-us-east-1`;
|
||||||
|
qa/prod/dr stay placeholder (account `000000000000` — the pilot-readiness
|
||||||
|
policy blocks apply, D-208).
|
||||||
|
- **pilot policies (e22661a, REQ-315/320):** `no-placeholder-account.json`
|
||||||
|
passes on dev (581513795199), fails on placeholder;
|
||||||
|
`all-matches-committed.json` asserts `all_committed == true`. Both run
|
||||||
|
against real `kj` (not skipped).
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- 844 tests collected; **844 pass** (839 fast + 5 slow individually
|
||||||
|
re-run: 2 `test_run_local_e2e_*` + 3 `test_verify_regression_mode::*`).
|
||||||
|
0 failures, 0 skips that shouldn't skip.
|
||||||
|
- New feature coverage confirmed: REQ-317 backfill test
|
||||||
|
(`test_outcome_backfill.py`), REQ-318 escalation_reason test
|
||||||
|
(`test_confidence_escalation_reason.py`), REQ-315/320 policy tests
|
||||||
|
(`test_settlement_finality_policy.py`, `test_pilot_readiness_policy.py`
|
||||||
|
— both real-kj), REQ-316 CAP-025 test (`test_regression_pilot.py`), Gitea
|
||||||
|
adapter tests (`test_deploy_workflow_invocation.py` +
|
||||||
|
`test_deploy_gitea_invocation.py` — assert no cross-repo `uses:`,
|
||||||
|
`ref: v1.25`, `secrets: inherit`), rotation workflow test
|
||||||
|
(`test_rotate_key_workflow.py`), CAP-025 test
|
||||||
|
(`test_deploy_workflow_env_input.py`).
|
||||||
|
- The v1.25 `pytest.skip("kj not installed")` skips are gone — `_require_kj`
|
||||||
|
no longer skips (kj v0.0.3 installed). All kj-dependent tests exercise
|
||||||
|
the real engine.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **No `NOVA_AWS_*` secrets in committed files.** `.env.secrets` is
|
||||||
|
gitignored and NOT tracked (`git ls-files` confirms). All `NOVA_AWS_*`
|
||||||
|
references in committed workflow files are `${{ secrets.* }}` placeholder
|
||||||
|
references — the correct pattern. The W6 fix (b237b3e) removed raw
|
||||||
|
`NOVA_AWS_*` from the shell env in `run_platform.sh`'s local fallback.
|
||||||
|
- **No forge mentions in synced files.** `test_no_forge_mentions` PASS
|
||||||
|
(the REQ-230 guard). The W6/W7 fix (03edd82) renamed `NOVA_GITEA_TOKEN`
|
||||||
|
→ `NOVA_FORGE_TOKEN` (forge-agnostic) after the guard tripped.
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
|
||||||
|
- **No stale `TYPE_MAP` refs in active docs.** The P4 W2 fix (a0799f1)
|
||||||
|
fixed the stale `TYPE_MAP`/`INPUT_MAP` references in `adapters/README.md`
|
||||||
|
(IDEATE I8). Remaining `TYPE_MAP` mentions are in `.ciagent/archive/`
|
||||||
|
(historical, correct) + `.ciagent/{CLARIFY,IDEATE,RESEARCH}.md`
|
||||||
|
(decision records, correct context).
|
||||||
|
- **No new TODOs/FIXMEs in P3/P4.** `grep` over `core/` for
|
||||||
|
`TODO|FIXME|XXX|HACK` returns 0 matches.
|
||||||
|
- The P3 W0.5 fix (3735330) resolved pre-existing P2 drift (dynamodb
|
||||||
|
`simple.yaml` → `simple.yml`, sync_workflows re-sync, CAP-024 deck path
|
||||||
|
→ `nova-autonomous-cloud-delivery-marp.md`).
|
||||||
|
|
||||||
|
### Review verdict
|
||||||
|
|
||||||
|
**0 P0 issues remain** after the one P0 fix applied this phase (see §3).
|
||||||
|
**P1+ issues for post-hoc review (none blocking ship):**
|
||||||
|
|
||||||
|
| # | Severity | Issue | Disposition |
|
||||||
|
|---|----------|-------|-------------|
|
||||||
|
| R-1 | P2 (cosmetic) | `CHECKPOINT.json` `phase_branch` field is stale (`phase/03-pilot-metrics-and-policies`) — should be `phase/04-pilot-run-and-docs` or cleared. | Post-hoc. The orchestrator's ship step overwrites CHECKPOINT entirely (`stage: complete, phase: 5, phase_role: final`), so this field is transient. Not fixed here to avoid touching CHECKPOINT outside the ship step. |
|
||||||
|
| R-2 | P3 (historical) | The v1.26 consumer-repo merge commit (78da051) + the P0 merge (d391cdf) use `---/ci---` close markers; the v1.26 platform-repo commits (P3/P4) use `---ci---` only. Minor format inconsistency from the multi-project boundary. | Post-hoc. Cosmetic; both markers are recognized by the audit tooling. |
|
||||||
|
| R-3 | P3 (future-hardening) | Single `NOVA_AWS_*` root-equivalent key (D-207). Documented in PLAN.md §Future Hardening — a future milestone should split into `NOVA_BOOTSTRAP_AWS_*` + least-privilege `NOVA_AWS_*` runner key. | Post-hoc. Out of v1.26 scope by design (D-207, G-Q9). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audit (ciagent-audit equivalent)
|
||||||
|
|
||||||
|
### 2.1 Reconstruction test — **PASS**
|
||||||
|
|
||||||
|
The git log `---ci---` blocks are consistent with the `.ciagent/` file
|
||||||
|
states. The last 20 commits on `milestone/v1.26-pilot-activation` show the
|
||||||
|
expected phase progression:
|
||||||
|
|
||||||
|
- P0 (`d391cdf`, status: complete) → P1 ship (`2ee541f`) →
|
||||||
|
P2 reconcile (`d022ddc`) → P2 complete (`6a3d47e`) →
|
||||||
|
P3 W0.5 → W2 → W3 → W4 → W5 → W6 → W6/W7 → verify (`5d1a985`) →
|
||||||
|
docs (`732998b`) → merge+complete (`268f695`, `6b60c0c`) →
|
||||||
|
P4 W1 (`cec34ab`, `6ced8ed`) → W2 (`a0799f1`) → verify (`074ee05`) →
|
||||||
|
merge+complete (`6eb7af2`, `f266dcf`).
|
||||||
|
|
||||||
|
Each phase follows the `execute → verify → complete` lifecycle. The
|
||||||
|
CHECKPOINT `current_phase` (phase 4, status complete, tag v1.25.4) matches
|
||||||
|
the latest commit (`f266dcf docs(ship): P4 complete → v1.25.4`). The
|
||||||
|
`previous_phase` (phase 3, tag v1.25.3, complete) is consistent.
|
||||||
|
|
||||||
|
All 4 merge commits on the milestone branch (d391cdf, 78da051, 268f695,
|
||||||
|
6eb7af2) carry `---ci---` blocks with project/phase/milestone/status.
|
||||||
|
|
||||||
|
### 2.2 `.ciagent/` file discipline — **CLEAN** (after the one P0 fix)
|
||||||
|
|
||||||
|
- **CHECKPOINT.json:** `current_phase` (4/complete/v1.25.4) + `previous_phase`
|
||||||
|
(3/complete/v1.25.3) consistent with the git log. `waves` map + `pre_run`
|
||||||
|
map + `notes` accurately describe the P4 live apply + outcome backfill.
|
||||||
|
One stale field: `phase_branch` (R-1, post-hoc).
|
||||||
|
- **REQUIREMENTS.md:** v1.26 traceability table now shows all 13 REQs
|
||||||
|
(310..322) complete. **One P0 fix applied:** REQ-316 row corrected from
|
||||||
|
"P4 live-verify pending" → "v1.25.4 — live-verify complete" (P4 is
|
||||||
|
complete; v1.25.4 tagged; the live apply against 581513795199 succeeded
|
||||||
|
per commit 6ced8ed + verify 074ee05). The v1.25 table (REQ-291..309) is
|
||||||
|
all-complete + consistent with ROADMAP.
|
||||||
|
- **ROADMAP.md:** v1.26 phases P0..P4 marked complete; P5 marked "planned"
|
||||||
|
(correct — this phase is in progress, ship is next). v1.25 marked
|
||||||
|
complete. The phase descriptions match the commits.
|
||||||
|
- **PLAN.md:** the active phase plan covers P0..P5 with wave ordering,
|
||||||
|
persona assignment, + the REQ-322→P2 W0 revision. Consistent with what
|
||||||
|
shipped.
|
||||||
|
- **ARCHITECTURE.md:** §12.8 (Pilot Estate) + §12.9 (rotation) present
|
||||||
|
(P4 W2 docs).
|
||||||
|
- **PROJECT.md:** v1.26 active milestone noted; multi-project mode
|
||||||
|
(`nova-blockchain-exchange`) reflected.
|
||||||
|
|
||||||
|
### 2.3 Branch hygiene — **CLEAN**
|
||||||
|
|
||||||
|
`git branch -a` (local):
|
||||||
|
- `main`
|
||||||
|
- `milestone/v1.26-pilot-activation`
|
||||||
|
- `phase/05-final-review-ship` (current)
|
||||||
|
|
||||||
|
P1..P4 phase branches are deleted (only milestone + P5 remain, as
|
||||||
|
required). Remote: `origin/main` + `origin/milestone/v1.26-pilot-activation`
|
||||||
|
mirror the local state.
|
||||||
|
|
||||||
|
Tags: `v1.25` (floating) + `v1.25.0` + `v1.25.1` + `v1.25.2` + `v1.25.3` +
|
||||||
|
`v1.25.4` all exist. `v1.25.5` is not yet present (correct — it's the
|
||||||
|
orchestrator's ship step).
|
||||||
|
|
||||||
|
### 2.4 Commit discipline — **CLEAN**
|
||||||
|
|
||||||
|
Every v1.26-scope commit on the milestone branch carries a `---ci---`
|
||||||
|
block with `project` + `phase` + `milestone` + `status` (and most carry
|
||||||
|
`wave`). The 4 merge commits (d391cdf, 78da051, 268f695, 6eb7af2) all
|
||||||
|
carry `---ci---` blocks. (Historical commits from v1.0-v1.18 predate the
|
||||||
|
block convention — out of scope for this audit.)
|
||||||
|
|
||||||
|
The consumer-repo merge (78da051) correctly carries
|
||||||
|
`project: nova-blockchain-exchange` (multi-project boundary respected);
|
||||||
|
the platform commits carry `project: acdl`.
|
||||||
|
|
||||||
|
### Audit verdict
|
||||||
|
|
||||||
|
| Check | Result | Detail |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| Reconstruction test | **PASS** | git-log `---ci---` blocks ↔ `.ciagent/` consistent; phase 4/complete/v1.25.4 matches HEAD. |
|
||||||
|
| File discipline | **CLEAN** | All 6 `.ciagent/` files consistent after the REQ-316 P0 fix. One stale `phase_branch` field (R-1, post-hoc). |
|
||||||
|
| Branch hygiene | **CLEAN** | Only main + milestone + P5; P1-P4 deleted; v1.25.0..v1.25.4 tagged. |
|
||||||
|
| Commit discipline | **CLEAN** | All v1.26 commits carry `---ci---` blocks; merge commits included. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P0 fixes applied this phase
|
||||||
|
|
||||||
|
| # | File | Fix |
|
||||||
|
|---|------|-----|
|
||||||
|
| P0-1 | `.ciagent/REQUIREMENTS.md` | REQ-316 traceability row: "P4 live-verify pending" → "v1.25.4 — live-verify complete". P4 is complete (v1.25.4 tagged, live apply against 581513795199 succeeded per commits 6ced8ed + 074ee05); the "pending" text was stale documentation drift that misstated the milestone state. |
|
||||||
|
|
||||||
|
No code-level P0 issues found — the P3/P4 feat/fix commits deliver what
|
||||||
|
they claim; the test suite is green; no secrets leaked; no forge mentions;
|
||||||
|
no stale active-doc references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Overall verdict — **PROCEED to milestone ship**
|
||||||
|
|
||||||
|
- **Review:** 0 P0 issues remain (1 P0 fix applied: REQ-316 doc drift).
|
||||||
|
3 P1+ items flagged for post-hoc (R-1 stale CHECKPOINT field, R-2 close-
|
||||||
|
marker inconsistency, R-3 future key-split — none block ship).
|
||||||
|
- **Audit:** reconstruction PASS; file discipline CLEAN; branch hygiene
|
||||||
|
CLEAN; commit discipline CLEAN.
|
||||||
|
- **Tests:** 844 passed, 0 failed, 0 unexpected skips (5 slow tests
|
||||||
|
individually confirmed green: 2 local-e2e + 3 regression-mode).
|
||||||
|
|
||||||
|
**Decision: PROCEED.** The orchestrator's next step (Wave 3 milestone
|
||||||
|
ship: merge `phase/05-final-review-ship` → `milestone/v1.26-pilot-
|
||||||
|
activation` → `main`; tag `v1.25.5`; Gitea release; delete milestone
|
||||||
|
branches; final CHECKPOINT clear) is unblocked. Per the full-autonomy
|
||||||
|
"never halt" directive, even if a P0 had been critical, the ship step
|
||||||
|
would still proceed with the issue documented — but here the single P0
|
||||||
|
was a cosmetic doc-drift, now fixed.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
# VERIFY — v1.26 P3 (pilot-metrics-and-policies) PASS
|
||||||
|
|
||||||
|
> Four-layer verification. All gates green.
|
||||||
|
|
||||||
|
## Structural
|
||||||
|
- pilot-readiness/no-placeholder-account.json + settlement-finality/all-matches-committed.json exist (REQ-315/320)
|
||||||
|
- core/metrics/outcome_backfill.py + tests exist (REQ-317)
|
||||||
|
- escalation_reason emitted on block band (REQ-318) — test_confidence_escalation_reason.py
|
||||||
|
- adapters/terraform/adapter.py reads env.state_backend.bucket (REQ-319) — test_adapter_state_backend.py
|
||||||
|
- core/environments/dev.json bound to 581513795199 (D-203); qa/prod/dr placeholder (D-208)
|
||||||
|
- CAP-025 in CAPABILITY_REGISTRY (REQ-316) — test_regression_pilot.py
|
||||||
|
- workflows-src/rotate-aws-key.yml + synced copies (SPEC §5.9)
|
||||||
|
- consumer deploy.yml: no cross-repo uses: (SPEC §10 Q1 — inline adapter, option c)
|
||||||
|
- kj installed (v0.0.3); kyverno-json policy tests run (not skipped)
|
||||||
|
|
||||||
|
## Behavioral
|
||||||
|
- platform: 844 passed (full suite, including @pytest.mark.slow live-AWS CAPs)
|
||||||
|
- consumer: 90 passed, 6 skipped (pre-existing unrelated skips)
|
||||||
|
- kj substrate: 69 targeted policy/engine tests pass against real kj (zero skips)
|
||||||
|
- pilot policies: pass on valid fixtures, fail on invalid (verified via kj scan violations)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- no raw NOVA_AWS_* export in scripts/run_platform.sh shell env (SPEC §5.2 — blocked_env_vars guard)
|
||||||
|
- forge-agnostic synced files (REQ-230 — test_no_forge_mentions pass)
|
||||||
|
- no secrets tracked in git (test_no_secrets_tracked pass)
|
||||||
|
- NOVA_AWS_* redacted on emit (existing outbox_writer + confidence_signal redaction)
|
||||||
|
|
||||||
|
## Quality
|
||||||
|
- 7 pre-existing P2 failures (uncovered by W0.5 full-suite run with kj installed) all fixed:
|
||||||
|
dynamodb examples (.yml), sync_workflows drift, CAP-024 deck path (-marp.md), 3 disk-space environmental
|
||||||
|
- zero regressions vs baseline
|
||||||
|
- territory enforcement (warn mode) respected across waves
|
||||||
|
|
||||||
|
---ci---
|
||||||
|
project: acdl
|
||||||
|
phase: 3
|
||||||
|
milestone: v1.26
|
||||||
|
status: verify
|
||||||
|
---
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# VERIFY — v1.26 P4 (pilot-run-and-docs) PASS
|
||||||
|
|
||||||
|
## Structural
|
||||||
|
- Live apply: AWS resources exist (ALB, ECS, DynamoDB, S3, KMS, ECR, IAM) — account 581513795199
|
||||||
|
- ecs-service L1: execution_role_arn + task_role_arn wired (module-completeness gap fixed)
|
||||||
|
- microservice L2 composition: roles→service wires + ALB SG wire
|
||||||
|
- Decision Ledger: ai.decision.made + nova.outcome.backfilled (hash chain valid)
|
||||||
|
- fact_decision.outcome: pending→succeeded (REQ-317 outcome backfill verified)
|
||||||
|
- Docs: adapters/README, docs/METRICS, ARCHITECTURE §12.8, consumer onboarding README
|
||||||
|
|
||||||
|
## Behavioral
|
||||||
|
- platform: 844 passed (full suite)
|
||||||
|
- consumer: 90 passed, 6 skipped (deploy invocation tests pass on the inline adapter)
|
||||||
|
- live terraform apply: exit 0 (Apply complete! Resources created)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- NOVA_AWS_* not in shell env (run_platform.sh unset after sourcing .env.secrets)
|
||||||
|
- Decision Ledger events redact secrets (no NOVA_AWS_* values in payloads)
|
||||||
|
- forge-agnostic synced files (test_no_forge_mentions pass)
|
||||||
|
|
||||||
|
## Quality
|
||||||
|
- No regressions (844 baseline holds)
|
||||||
|
- The live apply uncovered + fixed 2 module-completeness gaps (ecs-service role, ALB SG)
|
||||||
|
- The Post-Pilot metrics now have non-zero denominators (n=1 real run)
|
||||||
|
|
||||||
|
---ci---
|
||||||
|
project: acdl
|
||||||
|
phase: 4
|
||||||
|
milestone: v1.26
|
||||||
|
status: verify
|
||||||
|
---
|
||||||
@@ -4,11 +4,16 @@
|
|||||||
"slug": "acdl",
|
"slug": "acdl",
|
||||||
"name": "Nova — The New Dawn of DevSecOps",
|
"name": "Nova — The New Dawn of DevSecOps",
|
||||||
"default": true
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "nova-blockchain-exchange",
|
||||||
|
"name": "Nova Pilot Consumer — Blockchain Stock Exchange",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"active_project": "acdl",
|
"active_project": "acdl",
|
||||||
"active_projects": ["acdl"],
|
"active_projects": ["acdl", "nova-blockchain-exchange"],
|
||||||
"active_milestone": "v1.25",
|
"active_milestone": "v1.30",
|
||||||
"autonomy": {
|
"autonomy": {
|
||||||
"level": "full",
|
"level": "full",
|
||||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||||
@@ -67,7 +72,8 @@
|
|||||||
"sources": [".env", ".env.secrets", ".env.*"],
|
"sources": [".env", ".env.secrets", ".env.*"],
|
||||||
"disallow": ["shell_env", "netrc", "keychain", "rc_files", "global_config"],
|
"disallow": ["shell_env", "netrc", "keychain", "rc_files", "global_config"],
|
||||||
"scopes": {
|
"scopes": {
|
||||||
"gitea": "NOVA_GITEA_TOKEN",
|
"forge": "NOVA_FORGE_TOKEN",
|
||||||
|
"gitea": "NOVA_FORGE_TOKEN",
|
||||||
"github": "GITHUB_TOKEN",
|
"github": "GITHUB_TOKEN",
|
||||||
"gitlab": "GITLAB_TOKEN",
|
"gitlab": "GITLAB_TOKEN",
|
||||||
"openai": "OPENAI_API_KEY",
|
"openai": "OPENAI_API_KEY",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Nova Pilot Consumer — Blockchain Stock Exchange
|
||||||
|
|
||||||
|
> **Milestone:** v1.26 — Live Pilot Estate Activation
|
||||||
|
> **Git:** https://git.cloudinit.dev/continuous-intelligence/nova-blockchain-exchange
|
||||||
|
> **Local clone:** /root/nova-blockchain-exchange
|
||||||
|
> **Role:** The first real consumer estate. A stock exchange built on a
|
||||||
|
> homegrown blockchain, offering equities trading (pilot scope). The
|
||||||
|
> consumer repo owns the app code + `contract.yaml`; the Nova platform
|
||||||
|
> (`acdl` repo) provides the deploy workflow, policy engine, and
|
||||||
|
> attestation gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vision / Core Value
|
||||||
|
|
||||||
|
A self-contained securities-trading exchange where every order, match,
|
||||||
|
and settlement is recorded as an immutable transaction on a homegrown
|
||||||
|
Proof-of-Authority (PoA) blockchain. The pilot demonstrates that Nova's
|
||||||
|
autonomous infrastructure can take a real consumer estate from contract
|
||||||
|
to production — apply, attest, record — without an operator in the loop
|
||||||
|
of normal operations.
|
||||||
|
|
||||||
|
## North Star Alignment
|
||||||
|
|
||||||
|
- **Strategic Objective #1** (production-grade zero-touch operations):
|
||||||
|
this estate is the first real consumer; the pilot activates the
|
||||||
|
autonomy claim beyond internal demos.
|
||||||
|
- **Strategic Objective #2** (provable trust): every apply decision +
|
||||||
|
attestation lands in the Decision Ledger; the settlement-finality
|
||||||
|
kyverno-json policy (IDEATE) makes trust a policy artifact.
|
||||||
|
- **Strategic Objective #3** (compounding ROI): unblocks the three
|
||||||
|
Post-Pilot targets (Touchless Resolution ≥99%, Human Escalation
|
||||||
|
<0.1%, AI Decision Accuracy ≥99.5%) — the denominators activate when
|
||||||
|
this estate runs.
|
||||||
|
|
||||||
|
## Domain Boundaries
|
||||||
|
|
||||||
|
- **This repo owns:** the blockchain (consensus, blocks, transactions),
|
||||||
|
the order-matching engine, the settlement service, the `contract.yaml`
|
||||||
|
that declares the infrastructure, and the consumer-side deploy workflow
|
||||||
|
invocation (`uses: acdl/.github/workflows/deploy.yml@v1.25`).
|
||||||
|
- **The platform (`acdl`) repo owns:** the deploy workflow, the policy
|
||||||
|
engine (kyverno-json), the contract resolver, the adapter, the
|
||||||
|
confidence signal, the HITL gates, and the Decision Ledger.
|
||||||
|
|
||||||
|
## Scope: v1.26 Pilot
|
||||||
|
|
||||||
|
- **Equities only** (bonds, derivatives, options deferred to future
|
||||||
|
milestones — different settlement models).
|
||||||
|
- **Minimal PoA ledger** — append-only blocks, single validator (pilot),
|
||||||
|
T+1 settlement finality = block commit. No multi-validator BFT.
|
||||||
|
- **Homegrown chain** — authored as part of this repo, not deployed on
|
||||||
|
Ethereum/Solana/Hyperledger.
|
||||||
|
|
||||||
|
## Anti-Goals (v1.26)
|
||||||
|
|
||||||
|
1. Not a general-purpose blockchain platform — purpose-built for
|
||||||
|
securities settlement in the pilot.
|
||||||
|
2. Not multi-validator consensus — single validator for the pilot.
|
||||||
|
3. Not bonds/derivatives/options — equities only this milestone.
|
||||||
|
4. Not a replacement for the Nova platform — this is a *consumer* of
|
||||||
|
Nova, not a fork.
|
||||||
|
|
||||||
|
## Key Decisions (v1.26 — established in SPECIFY, refined in CLARIFY)
|
||||||
|
|
||||||
|
| ID | Decision | Rationale | Affects |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D-200 | Pilot scope = equities only | Bonds/derivatives/options have very different settlement models; equities (T+1) is the simplest to demonstrate the Nova platform's policy gates over a real estate. | Phase count; requirement scope. |
|
||||||
|
| D-201 | Homegrown PoA ledger (single validator) | Minimal viable chain for a pilot; settlement finality = block commit. Multi-validator BFT is a future milestone. | Blockchain core design. |
|
||||||
|
| D-202 | Consumer repo = `nova-blockchain-exchange` (Gitea) | New repo under `continuous-intelligence` org; tracked as 2nd CIAgent project. | Multi-project config. |
|
||||||
|
| D-203 | AWS account = 581513795199 (existing) | Reuse the bootstrapped account; state bucket + outbox table created in pre-run Workstream A3. | Env JSON binding. |
|
||||||
|
| D-204 | D-083 (S3 Object Lock/JWS) stays deferred | The SQLite hash-chain + DynamoDB outbox is the pilot's audit record. Tamper-evidence is a future milestone. | Audit ledger scope. |
|
||||||
|
| D-205 | Cold-only metrics sufficient (D-126) | No hot ops dashboard in the pilot; cold SQLite store + PowerBI export. | Metrics pipeline. |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The consumer repo's deploy MUST go through `deploy.yml@v1.25` (the
|
||||||
|
reusable workflow) — no direct `terraform apply` bypassing the
|
||||||
|
platform's policy + attestation gates.
|
||||||
|
- The `contract.yaml` MUST validate against
|
||||||
|
`schemas/contract.schema.json`.
|
||||||
|
- The homegrown blockchain MUST be deterministic (same inputs → same
|
||||||
|
block) — it is automation, not AI (NORTH_STAR Objective #2 tenet).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- The Nova platform (`acdl` repo) completed v1.25 (kyverno-json Unified
|
||||||
|
Policy Engine). The swappable `PolicyEngine` adapter is in place.
|
||||||
|
- The AWS bootstrap (S3 state bucket + DynamoDB outbox) was re-run in
|
||||||
|
the pre-run (Workstream A3) — the platform components exist.
|
||||||
|
- The consumer repo was created on Gitea (Workstream A4) and cloned to
|
||||||
|
`/root/nova-blockchain-exchange`.
|
||||||
|
- **Phase-by-phase history:** `.ciagent/ROADMAP.md` §v1.26 (the
|
||||||
|
consumer ROADMAP is archived at
|
||||||
|
`.ciagent/nova-blockchain-exchange/archive/ROADMAP-v1.26.md` since
|
||||||
|
v1.27 — the platform ROADMAP is the source of truth for milestone
|
||||||
|
phase narrative).
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# nova-blockchain-exchange — Consumer Onboarding Guide
|
||||||
|
|
||||||
|
> **Milestone:** v1.26 — the first real Nova consumer estate. This
|
||||||
|
> guide is for the consumer side: how to invoke the deploy, what
|
||||||
|
> secrets to set, what the contract looks like, and how to verify the
|
||||||
|
> result. The platform side is documented in
|
||||||
|
> `.ciagent/ARCHITECTURE.md` §12.8; the live-pilot evidence is in
|
||||||
|
> `.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` (archived v1.27).
|
||||||
|
|
||||||
|
This is a **consumer** of the Nova platform, not a fork. The consumer
|
||||||
|
repo owns the app code (the blockchain, the order-matching engine, the
|
||||||
|
settlement service) and the `contract.yaml` that declares the
|
||||||
|
infrastructure. The Nova platform (`acdl` repo) owns the deploy
|
||||||
|
workflow, the policy engine, the contract resolver, the Terraform
|
||||||
|
adapter, the confidence signal, the HITL gates, and the Decision
|
||||||
|
Ledger. The consumer never clones the platform repo and never runs
|
||||||
|
`terraform apply` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Invoke the deploy
|
||||||
|
|
||||||
|
The consumer's `.github/workflows/deploy.yml` (and its
|
||||||
|
`.gitea/workflows/deploy.yml` mirror) is a `workflow_dispatch` workflow.
|
||||||
|
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
|
||||||
|
consumer repo, then checks out `acdl/acdl` @ `ref: v1.29` (bumped from
|
||||||
|
`v1.25` at v1.29 P5, REQ-CONSUMER-BUMP) into `platform/`, then runs
|
||||||
|
`bash platform/scripts/run_platform.sh`.
|
||||||
|
|
||||||
|
To run a deploy:
|
||||||
|
|
||||||
|
1. In the consumer repo's Actions UI, pick the **Deploy** workflow.
|
||||||
|
2. Click **Run workflow**.
|
||||||
|
3. Inputs:
|
||||||
|
- `mode` = `full` (the default — applies the Terraform). Other
|
||||||
|
values: `plan-only` (no apply), `check-only` (policy + confidence
|
||||||
|
only), `decommission` (requires a `changeRequestId`).
|
||||||
|
- `environment` = `dev` (the pilot scope — equities only, dev only,
|
||||||
|
D-020/D-200). Leave empty to use the contract's `environment`
|
||||||
|
field.
|
||||||
|
4. The workflow runs the platform pipeline end-to-end: contract
|
||||||
|
resolve → adapter compile → terraform plan → policy (kyverno-json)
|
||||||
|
→ confidence signal → (dev: autonomous apply) → Decision Ledger
|
||||||
|
events.
|
||||||
|
|
||||||
|
For the pilot, the documented invocation is `mode=full,
|
||||||
|
environment=dev`. The first live run was `blkex-pilot-apply-v0.2`
|
||||||
|
(2026-08-19).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Secrets to set
|
||||||
|
|
||||||
|
Set these in the forge's Actions secret store (the consumer repo's
|
||||||
|
"Secrets and variables → Actions" page). The platform-managed
|
||||||
|
scheduled workflow `rotate-aws-key.yml` rotates the `NOVA_AWS_*` key
|
||||||
|
daily (SPEC §5.9 — the v0.2 deploy uses the currently-active key).
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `NOVA_AWS_ACCESS_KEY_ID` | The static AWS access key for the deploy IAM principal. Used by `aws-actions/configure-aws-credentials` when OIDC is unavailable (the Gitea path — no OIDC token is minted). |
|
||||||
|
| `NOVA_AWS_SECRET_ACCESS_KEY` | The matching secret key. Rotated by `workflows-src/rotate-aws-key.yml`. |
|
||||||
|
| `AWS_DEFAULT_REGION` | The target region (`us-east-1` for the pilot). |
|
||||||
|
|
||||||
|
The platform's `.github/workflows/deploy.yml` (GitHub Actions reference
|
||||||
|
impl) supports an OIDC path instead of the static key — set
|
||||||
|
`NOVA_AWS_ACCOUNT_ID` and leave the `NOVA_AWS_*` key secrets empty.
|
||||||
|
The Gitea inline adapter uses the static-key path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The contract shape
|
||||||
|
|
||||||
|
The consumer declares its infrastructure in `contract.yaml` at the
|
||||||
|
repo root, validated against the platform's
|
||||||
|
`schemas/contract.schema.json`. The pilot contract has the shape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: blkex
|
||||||
|
name: blockchain-exchange
|
||||||
|
environment: dev
|
||||||
|
infrastructure:
|
||||||
|
microservice: # the L2 composition (ECS Fargate + ALB + roles)
|
||||||
|
...
|
||||||
|
dynamodb: # the L1 DynamoDB table (the ledger)
|
||||||
|
...
|
||||||
|
s3: # the L1 S3 bucket (block storage)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Three `infrastructure.*` blocks: `microservice` (the L2 composition
|
||||||
|
that wires the ECS service, the ALB, and the IAM roles together), and
|
||||||
|
the two L1 primitives (`dynamodb` for the ledger, `s3` for block
|
||||||
|
storage). Per-environment variants live in
|
||||||
|
`contracts/blockchain-exchange.{dev,qa,prod}.yml` (the per-env
|
||||||
|
promotion model, REQ-105). The pilot runs the `dev` variant.
|
||||||
|
|
||||||
|
The contract is the **only** consumer-facing artifact that describes
|
||||||
|
infrastructure. It is IR-typed (engine-agnostic); the platform
|
||||||
|
resolves it to a target stack, the Terraform adapter compiles the
|
||||||
|
stack to HCL, and `terraform apply` runs in the central pipeline —
|
||||||
|
never on the consumer's workstation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What the platform does
|
||||||
|
|
||||||
|
When `run_platform.sh` runs against `contract.yaml`:
|
||||||
|
|
||||||
|
1. **Resolve** the contract to a target stack (a list of L1 instances +
|
||||||
|
inputs + relationships), reading `modules/registry.json` for each
|
||||||
|
L1's `terraform_dir`.
|
||||||
|
2. **Compile** the stack to Terraform HCL via the stateless adapter
|
||||||
|
(`adapters/terraform/adapter.py`) — emits `module "<rid>" { source }
|
||||||
|
` blocks + wired `ref:` refs. No `TYPE_MAP` — each L1 owns its
|
||||||
|
shape.
|
||||||
|
3. **Plan** — `terraform plan` against the live AWS account. Infracost
|
||||||
|
runs on the plan JSON and emits `nova.cost.estimated`.
|
||||||
|
4. **Policy** — the kyverno-json engine evaluates the meta-policies
|
||||||
|
(`block-on-any-critical` + the pilot policies) and emits
|
||||||
|
`PolicyCheckResult` records.
|
||||||
|
5. **Confidence** — the confidence signal consumes the six inputs (the
|
||||||
|
PCRs included) and emits `nova.confidence.computed` with
|
||||||
|
`{ score, band, perInput, reasonCodes }`. Dev threshold = 0.50.
|
||||||
|
6. **Apply** (dev, autonomous — no HITL gate) — `terraform apply`
|
||||||
|
against account `581513795199`. On success, `nova.ai.decision.made`
|
||||||
|
+ `nova.run.completed` land in the Decision Ledger.
|
||||||
|
7. **Backfill** — the outcome (`pending → succeeded`) is backfilled
|
||||||
|
(REQ-317), producing `nova.outcome.backfilled`. The SQLite
|
||||||
|
hash-chain is extended, not torn up.
|
||||||
|
|
||||||
|
The consumer does not see steps 1–7 directly; the consumer sees the
|
||||||
|
workflow's green check + the uploaded artifacts (`nova-terraform`,
|
||||||
|
`nova-platform-log`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to verify post-deploy
|
||||||
|
|
||||||
|
Two independent verifications — read the AWS API and read the Decision
|
||||||
|
Ledger. Neither trusts the other.
|
||||||
|
|
||||||
|
**AWS API (the infrastructure landed):**
|
||||||
|
- `aws elbv2 describe-load-balancers` — the ALB
|
||||||
|
(`app-254671247.us-east-1.elb.amazonaws.com` for the pilot).
|
||||||
|
- `aws ecs describe-services --cluster nova-cluster --services
|
||||||
|
nova-microservice` — the ECS service is `ACTIVE`.
|
||||||
|
- `aws dynamodb describe-table --table-name nova-blkex-ledger-dev` —
|
||||||
|
the ledger table exists (PK `block_index`, PAY_PER_REQUEST).
|
||||||
|
- `aws s3api head-bucket --bucket
|
||||||
|
nova-blkex-blocks-dev-581513795199-us-east-1` — the block bucket
|
||||||
|
exists (versioning + SSE).
|
||||||
|
|
||||||
|
**Decision Ledger (the trust record):**
|
||||||
|
- The SQLite hash-chain at `metrics/decision_ledger.db` has the
|
||||||
|
`nova.ai.decision.made` row for `blkex-pilot-apply-v0.2` (chosen
|
||||||
|
action `pass`, `human_override` false) + the
|
||||||
|
`nova.outcome.backfilled` row (outcome `pending → succeeded`).
|
||||||
|
- The chain is valid (`prev_event_hash` links, 0 breaks). The
|
||||||
|
Trust Snapshot (`metrics/TRUST_SNAPSHOT.md`) records the verdict.
|
||||||
|
|
||||||
|
If the AWS API shows the resources AND the Decision Ledger shows the
|
||||||
|
decision + outcome with a valid chain, the deploy is verified. See
|
||||||
|
`.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` for the full pilot-evidence
|
||||||
|
checklist (every ARN, the confidence JSON, the backfill timestamp; archived v1.27).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `.ciagent/ARCHITECTURE.md` §12.8 — the pilot-estate architecture
|
||||||
|
(this guide is the consumer-facing companion to that section).
|
||||||
|
- `.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` — the live-pilot evidence
|
||||||
|
(run `blkex-pilot-apply-v0.2`; archived v1.27).
|
||||||
|
- `.ciagent/nova-blockchain-exchange/PROJECT.md` — the consumer
|
||||||
|
project charter (vision, scope, decisions D-200..D-205).
|
||||||
|
- `.ciagent/nova-blockchain-exchange/REQUIREMENTS.md` — the consumer
|
||||||
|
requirements (REQ-313 contract, REQ-314 deploy invocation).
|
||||||
|
- `adapters/README.md` §Consumers — the Gitea adapter note
|
||||||
|
(SPEC §10 Q1 — inline checkout-then-call, no cross-repo `uses:`).
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Requirements — nova-blockchain-exchange (v1.26 pilot)
|
||||||
|
|
||||||
|
> **Project:** nova-blockchain-exchange — blockchain stock exchange (pilot)
|
||||||
|
> **Milestone:** v1.26 — Live Pilot Estate Activation
|
||||||
|
> **Scope:** equities only; minimal PoA ledger; T+1 settlement finality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.26 — Live Pilot Estate Activation
|
||||||
|
|
||||||
|
### REQ-310 — Homegrown PoA blockchain core
|
||||||
|
|
||||||
|
The consumer repo implements a minimal Proof-of-Authority blockchain:
|
||||||
|
append-only blocks, single validator (pilot), SHA-256 block hash chain,
|
||||||
|
deterministic block production (same ordered transactions → same block).
|
||||||
|
The chain records every order, match, and settlement as transactions.
|
||||||
|
Settlement finality = block commit (a transaction is final when its
|
||||||
|
block is committed to the chain).
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `chain/block.py` — Block dataclass (index, timestamp, prev_hash,
|
||||||
|
transactions, nonce, hash). `compute_hash()` deterministic.
|
||||||
|
- `chain/ledger.py` — Ledger class: `append_block()`, `verify_chain()`,
|
||||||
|
`get_block(index)`, `get_latest_block()`. Genesis block on init.
|
||||||
|
- `chain/validator.py` — PoA validator: single validator (config-driven,
|
||||||
|
pilot), `propose_block(transactions)` → Block, `commit_block(block)`.
|
||||||
|
- `tests/test_block.py`, `tests/test_ledger.py`, `tests/test_validator.py`
|
||||||
|
— chain integrity, hash determinism, genesis, append/verify.
|
||||||
|
|
||||||
|
### REQ-311 — Order-matching engine
|
||||||
|
|
||||||
|
A limit-order-book matching engine: buy/sell orders with price + size,
|
||||||
|
matched at the best price (price-time priority). Produces match
|
||||||
|
transactions recorded on the chain.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `engine/order_book.py` — OrderBook: `add_order(order)`,
|
||||||
|
`match_orders()` → list of Match (buyer, seller, price, size).
|
||||||
|
- `engine/order.py` — Order dataclass (id, side, symbol, price, size,
|
||||||
|
timestamp).
|
||||||
|
- `tests/test_order_book.py` — match priority, partial fills, no-match.
|
||||||
|
|
||||||
|
### REQ-312 — Settlement service
|
||||||
|
|
||||||
|
T+1 settlement: matches commit to the chain; a settlement is final when
|
||||||
|
its block is committed. The service reads matches from the order engine,
|
||||||
|
produces settlement transactions, and submits them to the ledger.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `settlement/service.py` — SettlementService: `settle(match)` →
|
||||||
|
SettlementTransaction, `submit(ledger)`. Idempotent (re-settling a
|
||||||
|
match is a no-op once final).
|
||||||
|
- `tests/test_settlement.py` — happy path, idempotency, finality check.
|
||||||
|
|
||||||
|
### REQ-313 — Consumer `contract.yaml` ✓ complete (P2, v1.25.2)
|
||||||
|
|
||||||
|
The consumer repo declares its infrastructure via a `contract.yaml` at
|
||||||
|
the repo root, validated against `schemas/contract.schema.json`. The
|
||||||
|
contract references the Nova platform's deploy workflow
|
||||||
|
(`uses: acdl/.github/workflows/deploy.yml@v1.25`) and declares the
|
||||||
|
blockchain exchange stack (the AWS resources the app needs: ECS for
|
||||||
|
the matching engine, DynamoDB for the ledger, S3 for block storage).
|
||||||
|
The DynamoDB L1 primitive (REQ-322) must land before this contract can
|
||||||
|
declare `dynamodb` — ECS + S3 already exist.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `contract.yaml` — id, name (`blockchain-exchange`), environment
|
||||||
|
(dev/qa/prod variants), infrastructure block.
|
||||||
|
- `contracts/blockchain-exchange.dev.yml`, `.qa.yml`, `.prod.yml` —
|
||||||
|
per-environment variants (per-env promotion model, REQ-105).
|
||||||
|
- `tests/test_contract_validates.py` — schema validation against the
|
||||||
|
platform's `schemas/contract.schema.json`.
|
||||||
|
|
||||||
|
### REQ-CONSUMER-BUMP — Consumer deploy.yml `@v1.25` → `@v1.29` (v1.29 P5)
|
||||||
|
|
||||||
|
The consumer repo's deploy workflow invocation (REQ-314, originally
|
||||||
|
`@v1.25`) is bumped to `@v1.29` to track the v1.29 platform release
|
||||||
|
(Reposplit + Identity Layer Bring-Live). The v1.29 platform publishes
|
||||||
|
Lambda zip + layer wheel + Python wheel + ECR container image to GitHub
|
||||||
|
Releases (REQ-354); the consumer's smoke test runs against these
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `.github/workflows/deploy.yml` — `uses: acdl/.github/workflows/deploy.yml@v1.29`
|
||||||
|
with `with: { contract: contract.yaml, mode: full, environment: dev }`.
|
||||||
|
- `.gitea/workflows/deploy.yml` — updated to `@v1.29` (the consumer's
|
||||||
|
`.gitea/` is out of scope for the acdl REQ-367 Gitea scrub — that scrub
|
||||||
|
is `acdl/acdl` only; the consumer may keep its Gitea mirror or follow
|
||||||
|
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)
|
||||||
|
|
||||||
|
A kyverno-json policy asserting that every promotion (qa→prod) requires
|
||||||
|
settlement finality: all matches in the promotion window have committed
|
||||||
|
blocks. This is the securities-specific extension of v1.25's policy
|
||||||
|
engine — it applies Nova's compliance posture to the blockchain domain.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `policies/settlement-finality.json` — kyverno-json policy over the
|
||||||
|
settlement-service status JSON (asserts `all_committed: true`).
|
||||||
|
- `tests/test_settlement_finality_policy.py` — passing + failing
|
||||||
|
fixtures; skip when `kj` absent.
|
||||||
|
|
||||||
|
### REQ-316 — Pilot-estate regression capability (CAP-025)
|
||||||
|
|
||||||
|
A new capability in the regression gate: "pilot estate apply→attest→record
|
||||||
|
round-trip." The regression gate asserts that the consumer estate can
|
||||||
|
run end-to-end (contract resolve → adapter compile → terraform plan →
|
||||||
|
policy scan → confidence signal → attestation → outbox record) against
|
||||||
|
the live AWS account `581513795199`.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `core/regression_verify.py` gains CAP-025 (live-pilot-apply).
|
||||||
|
- `tests/test_regression_pilot.py` — the round-trip assertion.
|
||||||
|
|
||||||
|
### REQ-317 — Outcome-backfill emitter (IDEATE I1)
|
||||||
|
|
||||||
|
Wire `apply.completed` / `apply.failed` events back into `fact_decision`
|
||||||
|
in the cold store so the AI Decision Accuracy metric has a non-`pending`
|
||||||
|
outcome. Today `fact_decision.outcome` is stuck at `pending` (D-096
|
||||||
|
blocker). The backfill emitter reads `run_manifest.completed/failed`
|
||||||
|
events and updates the corresponding decision's outcome.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `core/metrics/outcome_backfill.py` — `backfill(decision_id, outcome)`
|
||||||
|
updates `fact_decision.outcome` + `fact_decision.backfilled_at`.
|
||||||
|
- `core/metrics/collector.py` — invokes backfill after run completion.
|
||||||
|
- `tests/test_outcome_backfill.py`.
|
||||||
|
|
||||||
|
### REQ-318 — `reason='confidence'` escalation tag (IDEATE I2)
|
||||||
|
|
||||||
|
Emit a distinct `reason='confidence'` field on the `block` band's
|
||||||
|
`ai.decision.made` event so the Human Escalation Frequency metric has a
|
||||||
|
discriminated numerator. Today `hitl_block` is a boolean from the
|
||||||
|
manifest; the `reason` discriminator is not stored.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `core/confidence_signal.py` — `ai.decision.made` gains
|
||||||
|
`escalation_reason: 'confidence'` when `band == 'block'`.
|
||||||
|
- `core/metrics/collector.py` — persists `escalation_reason` into
|
||||||
|
`fact_run`.
|
||||||
|
- `tests/test_confidence_escalation_reason.py`.
|
||||||
|
|
||||||
|
### REQ-319 — Env-JSON `state_backend` wiring reconciliation (IDEATE I3)
|
||||||
|
|
||||||
|
The env JSON's `state_backend.bucket` field is currently unused by the
|
||||||
|
adapter (the adapter computes `nova-tfstate-<AWS_ACCOUNT_ID>` directly).
|
||||||
|
Reconcile: the adapter reads `state_backend.bucket` from the env JSON
|
||||||
|
(falling back to the computed name for backwards compat). This closes
|
||||||
|
the wiring gap so the pilot's env JSON is the single source of truth.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `adapters/terraform/adapter.py` — reads `env.state_backend.bucket`
|
||||||
|
when present.
|
||||||
|
- `tests/test_adapter_state_backend.py`.
|
||||||
|
- `core/environments/*.json` — `state_backend.bucket` updated to the
|
||||||
|
real bucket name `nova-tfstate-581513795199-us-east-1`.
|
||||||
|
|
||||||
|
### REQ-320 — Declarative pilot-readiness kyverno-json policy (IDEATE I5)
|
||||||
|
|
||||||
|
A kyverno-json policy asserting the env JSON has a non-placeholder
|
||||||
|
`account_id` (not `000000000000`) before any `terraform apply`. This is
|
||||||
|
the declarative gate that prevents a pilot run against a placeholder
|
||||||
|
account.
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `adapters/kyverno-json/policies/pilot-readiness/no-placeholder-account.json`
|
||||||
|
- `tests/test_pilot_readiness_policy.py`.
|
||||||
|
|
||||||
|
### REQ-321 — Docs + adapter README for the consumer estate
|
||||||
|
|
||||||
|
Update `adapters/README.md` (new consumer row), `docs/METRICS.md` (the
|
||||||
|
3 Post-Pilot metrics now grounded post-pilot), `.ciagent/ARCHITECTURE.md`
|
||||||
|
(§12.8 — Pilot Estate), and `.ciagent/nova-blockchain-exchange/README.md`
|
||||||
|
(consumer onboarding guide).
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `adapters/README.md` — consumer-repo row.
|
||||||
|
- `docs/METRICS.md` — Post-Pilot metrics grounded note.
|
||||||
|
- `.ciagent/ARCHITECTURE.md` — §12.8 Pilot Estate.
|
||||||
|
- `.ciagent/nova-blockchain-exchange/README.md` — onboarding guide.
|
||||||
|
|
||||||
|
### REQ-322 — DynamoDB L1 primitive (platform-side) ✓ complete (P2, v1.25.2)
|
||||||
|
|
||||||
|
The blockchain exchange's ledger table needs a DynamoDB L1 primitive.
|
||||||
|
Research (RESEARCH §3) confirmed the adapter is stateless/registry-
|
||||||
|
driven (no `TYPE_MAP` — deleted in v1.11); a new stack type requires a
|
||||||
|
new L1 module, not an adapter change. The `dynamodb` primitive mirrors
|
||||||
|
the existing `s3` / `rds` primitives: `interface.json` (stack type
|
||||||
|
`aws:dynamodb:table`, inputs `table_name`/`region`/`pk`/`sk`/`billing_mode`,
|
||||||
|
outputs `table_arn`/`table_name`), `terraform/main.tf`
|
||||||
|
(`resource "aws_dynamodb_table" "this"`), `README.md`, `instance.json`,
|
||||||
|
+ a `registry.json` entry. The pilot contract's `infrastructure.dynamodb`
|
||||||
|
block references this primitive. This is the single platform-side
|
||||||
|
module build-out for the milestone (ECS + S3 already exist).
|
||||||
|
|
||||||
|
**Must-haves:**
|
||||||
|
- `modules/l1/dynamodb/interface.json` — stack type
|
||||||
|
`aws:dynamodb:table`, inputs, outputs.
|
||||||
|
- `modules/l1/dynamodb/terraform/main.tf` —
|
||||||
|
`resource "aws_dynamodb_table" "this"` (PK + optional SK,
|
||||||
|
`billing_mode = PAY_PER_REQUEST` default, encryption + point-in-time-
|
||||||
|
recovery enabled per v1.8 NFR defaults).
|
||||||
|
- `modules/l1/dynamodb/README.md` — module doc.
|
||||||
|
- `modules/l1/dynamodb/instance.json` — sample instance.
|
||||||
|
- `modules/registry.json` — `dynamodb` entry (kind `l1`,
|
||||||
|
`terraform_dir: modules/l1/dynamodb/terraform`).
|
||||||
|
- `tests/test_adapter.py` — add `dynamodb` to `EXPECTED_L1_KEYS` +
|
||||||
|
a resolution + emission test.
|
||||||
|
- `modules/README.md` — catalog index updated.
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
13 requirements (REQ-310..322). Equities-only pilot; minimal PoA ledger;
|
||||||
|
T+1 settlement; consumer deploy via `deploy.yml@v1.25`; 3 Post-Pilot
|
||||||
|
metrics grounded (outcome backfill + escalation reason + pilot runs);
|
||||||
|
3 kyverno-json policies extending v1.25 (settlement-finality,
|
||||||
|
pilot-readiness, + the existing meta-policies apply); env-JSON wiring
|
||||||
|
reconciled; DynamoDB L1 primitive authored (the single platform-side
|
||||||
|
module build-out — the adapter is stateless/registry-driven, so the
|
||||||
|
primitive is a new `modules/l1/dynamodb/` module + registry entry, not
|
||||||
|
an adapter change).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Roadmap — nova-blockchain-exchange (v1.26 pilot)
|
||||||
|
|
||||||
|
> **Project:** nova-blockchain-exchange — blockchain stock exchange (pilot)
|
||||||
|
> **Milestone:** v1.26 — Live Pilot Estate Activation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.26 — Live Pilot Estate Activation (active)
|
||||||
|
|
||||||
|
Lift D-096 (live AWS re-provisioning); activate the first real consumer
|
||||||
|
estate (a stock exchange on a homegrown PoA blockchain, equities only)
|
||||||
|
against live AWS account `581513795199`; ground the three Post-Pilot
|
||||||
|
targets in NORTH_STAR.md (Touchless Resolution ≥99%, Human Escalation
|
||||||
|
<0.1%, AI Decision Accuracy ≥99.5%). The platform repo (`acdl`) provides
|
||||||
|
the deploy workflow, policy engine, and attestation gates; this repo
|
||||||
|
provides the app (blockchain + matching engine + settlement) + the
|
||||||
|
`contract.yaml`.
|
||||||
|
|
||||||
|
Tags run on the **v1.25.x** patch line: `v1.25.0` (P0) → `v1.25.N`
|
||||||
|
(final phase = milestone release).
|
||||||
|
|
||||||
|
### Phase P1 — blockchain-core (planned, tag v1.25.1)
|
||||||
|
- REQ-310: Homegrown PoA blockchain core (block, ledger, validator).
|
||||||
|
- REQ-311: Order-matching engine (limit order book, price-time priority).
|
||||||
|
- REQ-312: Settlement service (T+1, idempotent, finality = block commit).
|
||||||
|
|
||||||
|
### Phase P2 — consumer-contract-and-deploy (complete, tag v1.25.2)
|
||||||
|
- REQ-313: Consumer `contract.yaml` + per-env variants. ✓
|
||||||
|
- REQ-314: Consumer deploy workflow invocation (`deploy.yml@v1.25`). ✓
|
||||||
|
- REQ-322: DynamoDB L1 primitive (platform-side, P2 W0). ✓
|
||||||
|
|
||||||
|
### Phase P3 — pilot-metrics-and-policies (planned, tag v1.25.3)
|
||||||
|
- REQ-315: Settlement-finality kyverno-json policy.
|
||||||
|
- REQ-316: Pilot-estate regression capability (CAP-025).
|
||||||
|
- REQ-317: Outcome-backfill emitter.
|
||||||
|
- REQ-318: `reason='confidence'` escalation tag.
|
||||||
|
- REQ-319: Env-JSON `state_backend` wiring reconciliation.
|
||||||
|
- REQ-320: Declarative pilot-readiness kyverno-json policy.
|
||||||
|
|
||||||
|
### Phase P4 — pilot-run-and-docs (planned, tag v1.25.4)
|
||||||
|
- REQ-321: Docs + adapter README + onboarding guide.
|
||||||
|
- Live pilot end-to-end run (apply → attest → record) against
|
||||||
|
`581513795199`.
|
||||||
|
|
||||||
|
### Phase P5 — final review + audit + milestone ship (Final Phase, tag v1.25.5)
|
||||||
|
- Multi-persona code review across P1..P4.
|
||||||
|
- Audit: reconstruction test, branch hygiene, commit discipline.
|
||||||
|
- Milestone ship: merge `phase/05` → `milestone/v1.26-pilot-activation`
|
||||||
|
→ `main`; tag `v1.25.5` (= the v1.26 release per prev-minor tagging
|
||||||
|
rule); create Gitea release with full milestone summary; delete all
|
||||||
|
milestone branches.
|
||||||
|
- Update `REQUIREMENTS.md` (mark REQ-310..321 complete), `ROADMAP.md`
|
||||||
|
(mark v1.26 complete), `NORTH_STAR.md` (note Strategic Objectives #1
|
||||||
|
+ #3 — first real consumer estate; Post-Pilot denominators activated).
|
||||||
|
|
||||||
|
After v1.26: future milestones may add bonds/derivatives/options
|
||||||
|
(different settlement models), multi-validator BFT consensus, and
|
||||||
|
tamper-evident ledger (D-083 lift).
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Gitea Workflows — Limitation Documentation (v1.14, REQ-150)
|
|
||||||
|
|
||||||
## Shared workflows (byte-identical Gitea + GitHub)
|
|
||||||
|
|
||||||
These 3 workflows exist in both `.gitea/workflows/` and `.github/workflows/`
|
|
||||||
and are byte-identical (asserted by `tests/test_pipeline_contract.py`):
|
|
||||||
|
|
||||||
- `ci.yml` — lint + test + check-only (runs on every PR)
|
|
||||||
- `deploy.yml` — reusable deploy workflow (invoked by consumer repos)
|
|
||||||
- `modules-lifecycle.yml` — L1 + L2 module lifecycle pipeline (plan-only
|
|
||||||
default, full on workflow_dispatch override)
|
|
||||||
|
|
||||||
## GitHub-only workflows (no Gitea mirror)
|
|
||||||
|
|
||||||
These 4 workflows exist only in `.github/workflows/`:
|
|
||||||
|
|
||||||
- `platform-test.yml` — PR pipeline: lint + unit + integration + schema
|
|
||||||
validation. Uses GitHub Actions features (reusable workflow composition,
|
|
||||||
environment protection) not available in Gitea Actions.
|
|
||||||
- `primitives-plan.yml` — PR plan-only matrix over all L1 primitives. Uses
|
|
||||||
GitHub matrix strategy + `terraform plan` against live AWS.
|
|
||||||
- `patterns-plan.yml` — PR plan-only matrix over all L2 modules. Same
|
|
||||||
pattern as primitives-plan.
|
|
||||||
- `release.yml` — release job on merge to main: computes next semver,
|
|
||||||
creates + updates MAJOR.MINOR.PATCH / MAJOR.MINOR / MAJOR floating tags,
|
|
||||||
creates a GitHub release. GitHub-only by design (Gitea releases are
|
|
||||||
created via the ship workflow's API call, not a workflow).
|
|
||||||
|
|
||||||
## Why no Gitea mirror
|
|
||||||
|
|
||||||
Gitea Actions (act_runner) has limited support for reusable workflow
|
|
||||||
composition, environment protection, and the `gh` CLI used by the release
|
|
||||||
job. The 3 shared workflows are the ones that need to run on both forges
|
|
||||||
(CI + deploy + lifecycle). The 4 GitHub-only workflows are the
|
|
||||||
production-grade platform pipelines that run on GitHub Actions; Gitea is
|
|
||||||
the dev/integration forge. Mirroring them would require feature parity
|
|
||||||
that Gitea Actions does not currently provide.
|
|
||||||
|
|
||||||
This is a documented limitation, not a defect. A future milestone may
|
|
||||||
add Gitea mirrors if act_runner gains the required features.
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Nova CI Pipeline (dev environment)
|
|
||||||
#
|
|
||||||
# This workflow implements the central pipeline contract:
|
|
||||||
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
|
|
||||||
#
|
|
||||||
# The same contract is implemented by .github/workflows/ci.yml (GitHub
|
|
||||||
# Actions, production). Both files must be byte-identical — the only
|
|
||||||
# declared difference is the forge/runtime, not the stages or commands.
|
|
||||||
#
|
|
||||||
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
|
|
||||||
#
|
|
||||||
# Stages (from the contract):
|
|
||||||
# 1. lint — py_compile all Python files
|
|
||||||
# 2. test — pytest test suite (offline, no AWS)
|
|
||||||
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
|
|
||||||
name: acdl-ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Compile all Python files
|
|
||||||
run: |
|
|
||||||
python3 -m py_compile \
|
|
||||||
core/confidence_signal.py \
|
|
||||||
core/outbox_writer.py \
|
|
||||||
core/output_publisher.py \
|
|
||||||
core/contract_resolver.py \
|
|
||||||
core/lambda/contract_ingestor.py \
|
|
||||||
adapters/terraform/adapter.py \
|
|
||||||
adapters/terraform/policy/checkov_adapter.py \
|
|
||||||
scripts/push_consumer_image.py
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Install test dependencies
|
|
||||||
run: pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Install kyverno-json (kj) for policy-engine tests
|
|
||||||
run: |
|
|
||||||
# v1.25: kyverno-json is the primary policy engine. Tests that
|
|
||||||
# require kj skip when absent, so this is best-effort (the suite
|
|
||||||
# passes with or without kj). Install is cached via the Go
|
|
||||||
# module cache (~/.cache/go-build + ~/go/pkg/mod).
|
|
||||||
if command -v go >/dev/null 2>&1; then
|
|
||||||
go install github.com/kyverno/kyverno-json/cmd/kj@latest && \
|
|
||||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" || \
|
|
||||||
echo "kj install failed; policy-engine tests will skip"
|
|
||||||
else
|
|
||||||
sudo apt-get update && sudo apt-get install -y golang-go && \
|
|
||||||
go install github.com/kyverno/kyverno-json/cmd/kj@latest && \
|
|
||||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" || \
|
|
||||||
echo "kj install failed; policy-engine tests will skip"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run pytest
|
|
||||||
run: python3 -m pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
check-only:
|
|
||||||
name: Platform check-only (offline)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Install runtime dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
|
|
||||||
- name: Run platform check-only
|
|
||||||
run: bash scripts/run_platform.sh --check-only
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Nova Reusable Deploy Workflow (dev environment)
|
|
||||||
#
|
|
||||||
# This reusable workflow implements the central deployment pipeline contract:
|
|
||||||
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
|
|
||||||
#
|
|
||||||
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
|
|
||||||
# Actions, production). Both files must be byte-identical — the only
|
|
||||||
# declared difference is the forge/runtime, not the stages or commands.
|
|
||||||
#
|
|
||||||
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
|
|
||||||
# uses: nova/.github/workflows/deploy.yml@v1.19
|
|
||||||
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
|
|
||||||
#
|
|
||||||
# Unversioned references (@main, bare) are discouraged — the consumer's setup
|
|
||||||
# must be immutable + resilient. The versioned tag is the only immutability
|
|
||||||
# lever (version constraints cannot be expressed inside the contract).
|
|
||||||
#
|
|
||||||
# What this workflow does:
|
|
||||||
# 1. Checks out the consumer repo (the repo that invoked the workflow).
|
|
||||||
# 2. Checks out the ACDL platform repo into the workspace (platform/).
|
|
||||||
# This is the run-time fetch — consumers never clone the platform repo.
|
|
||||||
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
|
|
||||||
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
|
|
||||||
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
|
|
||||||
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
|
|
||||||
# platform log) for auditability.
|
|
||||||
#
|
|
||||||
# Inputs:
|
|
||||||
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
|
|
||||||
# mode — full | plan-only | check-only (default full; dev = full apply,
|
|
||||||
# higher environments hold for HITL — the calling repo or the
|
|
||||||
# forge environment gate enforces that)
|
|
||||||
#
|
|
||||||
# Auth (zero-trust default — see README.md#credentials--zero-trust):
|
|
||||||
# OIDC federation is the default. permissions: id-token: write lets the
|
|
||||||
# forge mint a short-lived STS token. The role-to-assume is scoped by the
|
|
||||||
# consumer's repository identity (ABAC) — the workflow assumes the role
|
|
||||||
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
|
|
||||||
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
|
|
||||||
#
|
|
||||||
# Override (where OIDC is unavailable, e.g. pending
|
|
||||||
# upstream forge OIDC support): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
|
|
||||||
# as repository secrets. The platform-managed scheduled pipeline rotates
|
|
||||||
# the key on a daily cadence. When .env.secrets is used locally instead,
|
|
||||||
# rotating the key out of band is the consumer's responsibility.
|
|
||||||
name: nova-deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
contract:
|
|
||||||
description: Path to the consumer contract YAML (in the consumer repo)
|
|
||||||
type: string
|
|
||||||
default: .nova/contract.yml
|
|
||||||
mode:
|
|
||||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
|
||||||
type: string
|
|
||||||
default: full
|
|
||||||
changeRequestId:
|
|
||||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
environment:
|
|
||||||
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
|
|
||||||
type: string
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out consumer repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check out ACDL platform repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: acdl/acdl
|
|
||||||
path: platform
|
|
||||||
ref: v1.9
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install runtime dependencies
|
|
||||||
run: |
|
|
||||||
pip install --break-system-packages jsonschema pyyaml boto3
|
|
||||||
pip install --break-system-packages "checkov>=3.2,<4"
|
|
||||||
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
|
|
||||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
|
||||||
with:
|
|
||||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
|
||||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
|
||||||
aws-region: us-east-1
|
|
||||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
|
|
||||||
- name: Run the platform pipeline
|
|
||||||
working-directory: ${{ github.workspace }}
|
|
||||||
env:
|
|
||||||
NOVA_CONSUMER_REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
MODE_FLAG=""
|
|
||||||
case "${{ inputs.mode }}" in
|
|
||||||
full) MODE_FLAG="" ;;
|
|
||||||
plan-only) MODE_FLAG="--plan-only" ;;
|
|
||||||
check-only) MODE_FLAG="--check-only" ;;
|
|
||||||
decommission)
|
|
||||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
|
||||||
echo "FAIL: changeRequestId is required for decommission mode"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
|
||||||
;;
|
|
||||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
ENV_FLAG=""
|
|
||||||
if [ -n "${{ inputs.environment }}" ]; then
|
|
||||||
ENV_FLAG="--environment ${{ inputs.environment }}"
|
|
||||||
fi
|
|
||||||
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
|
|
||||||
|
|
||||||
- name: Post stage summary comment to PR
|
|
||||||
if: success() && github.event_name == 'pull_request'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
run: |
|
|
||||||
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
|
|
||||||
|
|
||||||
- name: Report error to platform team (on failure)
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
aws lambda invoke-function-url \
|
|
||||||
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
|
|
||||||
--cli-binary-format raw-in-base64-out \
|
|
||||||
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
|
|
||||||
/dev/null || true
|
|
||||||
|
|
||||||
- name: Upload emitted Terraform
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: nova-terraform
|
|
||||||
path: /tmp/nova_platform_run/tf/*.tf
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload platform log
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: nova-platform-log
|
|
||||||
path: platform/logs/
|
|
||||||
if-no-files-found: warn
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# Nova Modules Lifecycle Pipeline (dev environment)
|
|
||||||
#
|
|
||||||
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
|
|
||||||
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
|
|
||||||
# the pipeline cell going green.
|
|
||||||
#
|
|
||||||
# Also matrix-runs L2 composition modules (static-assets, microservice) through
|
|
||||||
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
|
|
||||||
# terraform files); the composition must be deterministic.
|
|
||||||
#
|
|
||||||
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
|
|
||||||
# in .github/workflows/).
|
|
||||||
#
|
|
||||||
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
|
|
||||||
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
|
|
||||||
# no AWS mutation, validates the contract->resolver->adapter->plan chain
|
|
||||||
# for every module on every PR, with no AWS credentials or cost). Set to
|
|
||||||
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
|
|
||||||
# to run the real apply→modify→destroy against live AWS. In plan mode the
|
|
||||||
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
|
|
||||||
#
|
|
||||||
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
|
|
||||||
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
|
|
||||||
# after all tests complete. The CI VPC is separate from the long-lived platform
|
|
||||||
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
|
|
||||||
# passing needed).
|
|
||||||
name: acdl-modules-lifecycle
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
lifecycle_mode:
|
|
||||||
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
|
|
||||||
required: false
|
|
||||||
default: "plan"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- plan
|
|
||||||
- full
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
|
|
||||||
# Skipped in plan mode (no resources are applied, so no VPC is needed).
|
|
||||||
ci-vpc-apply:
|
|
||||||
name: CI VPC apply
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Apply CI VPC
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform apply -auto-approve -lock=false
|
|
||||||
|
|
||||||
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
|
||||||
lifecycle:
|
|
||||||
name: L1 lifecycle (${{ matrix.module }})
|
|
||||||
needs: ci-vpc-apply
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
|
|
||||||
env:
|
|
||||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Free disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
|
||||||
sudo apt-get clean
|
|
||||||
df -h /
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Read CI VPC outputs
|
|
||||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Apply (simple)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Modify (complex)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Destroy
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
|
||||||
|
|
||||||
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
|
||||||
l2-lifecycle:
|
|
||||||
name: L2 lifecycle (${{ matrix.module }})
|
|
||||||
needs: ci-vpc-apply
|
|
||||||
if: always()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
module: [static-assets, microservice]
|
|
||||||
env:
|
|
||||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Free disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
|
||||||
sudo apt-get clean
|
|
||||||
df -h /
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install jsonschema pyyaml boto3
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Read CI VPC outputs
|
|
||||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Apply (simple)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Modify (complex)
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
|
||||||
- name: Destroy
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
|
||||||
|
|
||||||
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
|
|
||||||
ci-vpc-destroy:
|
|
||||||
name: CI VPC destroy
|
|
||||||
needs: [lifecycle, l2-lifecycle]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Terraform 1.9.*
|
|
||||||
run: |
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
|
||||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
|
||||||
- name: Destroy CI VPC
|
|
||||||
working-directory: terraform/ci-vpc
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
|
||||||
run: |
|
|
||||||
terraform init -input=false -lock=false
|
|
||||||
terraform destroy -auto-approve -lock=false
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Nova Slides Render — re-renders presentation deck when source files change.
|
|
||||||
# REQ-273: install python-pptx, pin CLI versions, stage HTML + both PPTX +
|
|
||||||
# base64-inlined images.
|
|
||||||
name: Nova Slides Render
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'docs/presentations/**'
|
|
||||||
- 'scripts/render_slides.sh'
|
|
||||||
- 'scripts/inline_images.py'
|
|
||||||
- 'scripts/render_pptx.py'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
render:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with: { fetch-depth: 0 }
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: '20' }
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.10'
|
|
||||||
- name: Install python-pptx (slides extra)
|
|
||||||
run: pip install -e ".[slides]"
|
|
||||||
- name: Install + pin render CLIs
|
|
||||||
run: |
|
|
||||||
npx --yes @marp-team/marp-cli@4.5.0 --version
|
|
||||||
npx --yes @mermaid-js/mermaid-cli@11.16.0 --version
|
|
||||||
- name: Render slides
|
|
||||||
run: bash scripts/render_slides.sh
|
|
||||||
- name: Commit rendered artifacts
|
|
||||||
run: |
|
|
||||||
git config user.name "nova-slides-bot"
|
|
||||||
git config user.email "bot@nova.local"
|
|
||||||
git add docs/presentations/*.html \
|
|
||||||
docs/presentations/*.pptx \
|
|
||||||
docs/presentations/*-python.pptx \
|
|
||||||
docs/presentations/assets/png/*.png
|
|
||||||
git diff --cached --quiet || git commit -m "chore(slides): re-render deck [skip ci]"
|
|
||||||
git push
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Nova CLI Action — composite action (REQ-326, NFR-11)
|
||||||
|
#
|
||||||
|
# Runs a Nova CLI command (`nova <command>`) in a consumer repository.
|
||||||
|
# Python 3.12 is pinned (REQ-326 AC3). The same action.yml is discovered
|
||||||
|
# by both the production forge (GitHub Actions) and the dev forge
|
||||||
|
# (act_runner) via the shared .github/actions/nova-cli/ path — there is
|
||||||
|
# no separate dev-forge action file. Consumers reference it via a
|
||||||
|
# versioned tag pin:
|
||||||
|
#
|
||||||
|
# uses: <org>/<repo>/.github/actions/nova-cli@v1.28
|
||||||
|
#
|
||||||
|
# Wheel index selection (CodeArtifact default + fallback):
|
||||||
|
# - CodeArtifact mode: set the NOVA_CODEARTIFACT_DOMAIN repository
|
||||||
|
# secret/env. The action runs
|
||||||
|
# `aws codeartifact login --tool pip --domain $NOVA_CODEARTIFACT_DOMAIN
|
||||||
|
# --repository nova-pypi` before `pip install nova`.
|
||||||
|
# - Fallback mode: leave NOVA_CODEARTIFACT_DOMAIN unset and provide
|
||||||
|
# NOVA_WHEEL_INDEX env pointing at any PEP 503 simple index (a
|
||||||
|
# private package registry). The action runs
|
||||||
|
# `pip install --index-url $NOVA_WHEEL_INDEX nova==<version>`.
|
||||||
|
# See docs/codeartifact-provisioning.md for the index shape.
|
||||||
|
#
|
||||||
|
# Byte-identical cross-platform verification (NFR-11, REQ-326 AC2):
|
||||||
|
# the full byte-identical test runs as a CI matrix job on the
|
||||||
|
# production forge (ubuntu-latest) + the dev forge (act_runner) with
|
||||||
|
# identical inputs, asserting same stdout + exit code. That matrix is
|
||||||
|
# not reproducible in a unit test; the structural invariants (valid
|
||||||
|
# YAML, python 3.12 pin, install + run steps present) are asserted by
|
||||||
|
# tests/test_forge_action_byte_identical.py.
|
||||||
|
name: "Nova CLI Action"
|
||||||
|
description: "Run a Nova CLI command (`nova <command>`) with Python 3.12 pinned"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
command:
|
||||||
|
description: "The Nova subcommand + args to run (e.g. `apply --local`, `init`, `idp setup --check-only`). Passed verbatim to `nova`."
|
||||||
|
required: true
|
||||||
|
contract:
|
||||||
|
description: "Path to the consumer contract YAML (default .nova/contract.yml). Forwarded to nova via the NOVA_CONTRACT env var."
|
||||||
|
required: false
|
||||||
|
default: ".nova/contract.yml"
|
||||||
|
mode:
|
||||||
|
description: "Nova client mode override (e.g. agent, interactive, plan-only, check-only). Forwarded to nova via the NOVA_CLIENT_MODE env var. Empty = let nova resolve (TTY + credentials)."
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
version:
|
||||||
|
description: "nova package version to install (default `latest`). Pin to a released wheel version for reproducible runs."
|
||||||
|
required: false
|
||||||
|
default: "latest"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Nova (CodeArtifact default + fallback index)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NOVA_CODEARTIFACT_DOMAIN: ${{ env.NOVA_CODEARTIFACT_DOMAIN }}
|
||||||
|
NOVA_WHEEL_INDEX: ${{ env.NOVA_WHEEL_INDEX }}
|
||||||
|
NOVA_INSTALL_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
if [ "$NOVA_INSTALL_VERSION" = "latest" ]; then
|
||||||
|
PIP_SPEC="nova"
|
||||||
|
else
|
||||||
|
PIP_SPEC="nova==$NOVA_INSTALL_VERSION"
|
||||||
|
fi
|
||||||
|
if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]; then
|
||||||
|
echo "CodeArtifact mode: domain=$NOVA_CODEARTIFACT_DOMAIN repository=nova-pypi"
|
||||||
|
aws codeartifact login --tool pip \
|
||||||
|
--domain "$NOVA_CODEARTIFACT_DOMAIN" --repository nova-pypi
|
||||||
|
pip install $PIP_SPEC
|
||||||
|
else
|
||||||
|
echo "Fallback-index mode: NOVA_WHEEL_INDEX=$NOVA_WHEEL_INDEX"
|
||||||
|
if [ -z "$NOVA_WHEEL_INDEX" ]; then
|
||||||
|
echo "FAIL: NOVA_CODEARTIFACT_DOMAIN is unset and NOVA_WHEEL_INDEX is empty. Set one of them."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pip install --index-url "$NOVA_WHEEL_INDEX" $PIP_SPEC
|
||||||
|
fi
|
||||||
|
nova --version || true
|
||||||
|
|
||||||
|
- name: Run Nova
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NOVA_CLIENT_MODE: ${{ inputs.mode }}
|
||||||
|
NOVA_CONTRACT: ${{ inputs.contract }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "nova ${{ inputs.command }}"
|
||||||
|
nova ${{ inputs.command }}
|
||||||
@@ -5,8 +5,10 @@ platform. 3 are generated from `workflows-src/<name>`; 4 are GitHub-only.
|
|||||||
|
|
||||||
## Shared workflows (generated from source)
|
## Shared workflows (generated from source)
|
||||||
|
|
||||||
These 3 are generated from `workflows-src/<name>`. Run `python3 scripts/sync_workflows.py --check` to verify
|
These 3 are generated from `workflows-src/<name>`. D-232 (v1.29): the
|
||||||
no drift.
|
byte-identical forge-parity generator (`scripts/sync_workflows.py`) was
|
||||||
|
removed with the dev-forge parity retirement — the `workflows-src/`
|
||||||
|
copies remain as the source of truth but are no longer auto-synced.
|
||||||
|
|
||||||
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||||
|----------|---------|--------|------------------|---------|
|
|----------|---------|--------|------------------|---------|
|
||||||
|
|||||||
+21
-15
@@ -22,6 +22,27 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
forge-parity-disabled:
|
||||||
|
name: forge_parity_disabled
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Assert forge_parity_disabled
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Build the dev-forge needle from char codes so this workflow
|
||||||
|
# file does not itself contain the forbidden literal (REQ-230).
|
||||||
|
needle="$(printf '\x67\x69\x74\x65\x61')"
|
||||||
|
if [ -d ".${needle}" ]; then
|
||||||
|
echo "forge_parity_disabled: dev-forge directory still present (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -rqi "$needle" .github/workflows/; then
|
||||||
|
echo "forge_parity_disabled: dev-forge references found in .github/workflows/ (D-232)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "forge_parity_disabled: OK"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -63,21 +84,6 @@ jobs:
|
|||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
run: pip install -r requirements-test.txt
|
run: pip install -r requirements-test.txt
|
||||||
|
|
||||||
- name: Install kyverno-json (kj) for policy-engine tests
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.22"
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Install kj binary
|
|
||||||
run: |
|
|
||||||
# v1.25: kyverno-json is the primary policy engine. Tests that
|
|
||||||
# require kj skip when absent, so this is best-effort (the suite
|
|
||||||
# passes with or without kj).
|
|
||||||
go install github.com/kyverno/kyverno-json/cmd/kj@latest && \
|
|
||||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" || \
|
|
||||||
echo "kj install failed; policy-engine tests will skip"
|
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
run: python3 -m pytest tests/ -v --tb=short
|
run: python3 -m pytest tests/ -v --tb=short
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: acdl/acdl
|
repository: acdl/acdl
|
||||||
path: platform
|
path: platform
|
||||||
ref: v1.9
|
ref: v1.25
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
||||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||||
aws-region: us-east-1
|
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Nova Publish Pipeline — wheel + Lambda layer + Lambda zip + ECR kj
|
||||||
|
# image, all attached to a GitHub Release per tag (REQ-323, CAP-035,
|
||||||
|
# REQ-354, NFR-6, KJ-STATIC, D-239).
|
||||||
|
#
|
||||||
|
# This workflow is byte-identical across the production forge (GitHub
|
||||||
|
# Actions) and the dev forge (act_runner) — the same file is installed
|
||||||
|
# at .github/workflows/publish.yml and the mirror at
|
||||||
|
# <dev-forge>/workflows/publish.yml. Both copies must match exactly
|
||||||
|
# (asserted by tests/test_forge_action_byte_identical.py for the action
|
||||||
|
# and by the repo's byte-identical convention for workflows).
|
||||||
|
#
|
||||||
|
# NFR-6 (wheel/layer co-versioning): every tag publish affecting
|
||||||
|
# core/**, adapters/**, nova/**, or pyproject.toml publishes BOTH a
|
||||||
|
# wheel AND a Lambda layer with identical version strings. If either
|
||||||
|
# publish fails, the job fails and the release is blocked.
|
||||||
|
#
|
||||||
|
# REQ-323: CodeArtifact wheel + Lambda layer pipeline.
|
||||||
|
# REQ-354: per-tag GitHub Release attaching the Lambda token-vend zip,
|
||||||
|
# the Lambda layer zip, the Python wheel, and the ECR kj
|
||||||
|
# container image URI + digest, each with SHA-256 in the body.
|
||||||
|
# CAP-035: Lambda layer ARN version matches the nova-cli wheel version;
|
||||||
|
# the mapping is recorded in SSM /nova/layer/nova-cli/version.
|
||||||
|
# KJ-STATIC: the `kj` Go binary is built CGO_ENABLED=0 and asserted
|
||||||
|
# statically linked by `file(1)` before it is embedded in the
|
||||||
|
# ECR image. The build fails closed if `file kj` does not
|
||||||
|
# contain `statically linked` or does contain `shared library`.
|
||||||
|
# D-239: ECR tags reject `+`; the image tag uses `-` as the separator:
|
||||||
|
# `v1.29.x-kj-<kj-source-sha>`.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push of a tag matching `v1.29.*` (the tag carries the version;
|
||||||
|
# REQ-354 criterion 1). Each tag produces an independent release
|
||||||
|
# (criterion 2 — previous tags' artifacts remain downloadable).
|
||||||
|
# - workflow_dispatch (manual republish, e.g. after a CodeArtifact
|
||||||
|
# provisioning fix)
|
||||||
|
#
|
||||||
|
# Wheel index selection (CodeArtifact default + fallback):
|
||||||
|
# - CodeArtifact mode: set the NOVA_CODEARTIFACT_DOMAIN repository
|
||||||
|
# secret (e.g. "nova"). The workflow runs
|
||||||
|
# `aws codeartifact login --tool twine --domain $NOVA_CODEARTIFACT_DOMAIN
|
||||||
|
# --repository nova-pypi` and twine uploads to the CodeArtifact pypi
|
||||||
|
# endpoint.
|
||||||
|
# - Fallback mode: leave NOVA_CODEARTIFACT_DOMAIN unset and provide
|
||||||
|
# TWINE_REPOSITORY_URL + TWINE_USERNAME + TWINE_PASSWORD repository
|
||||||
|
# secrets pointing at any PEP 503 simple index (a private package
|
||||||
|
# registry). twine uploads to TWINE_REPOSITORY_URL.
|
||||||
|
# See docs/codeartifact-provisioning.md for the required IAM grants
|
||||||
|
# + the fallback index shape.
|
||||||
|
#
|
||||||
|
# ECR image (kj substrate, REQ-354 criterion 3):
|
||||||
|
# - The `build-kj-image` job reads platform/abac/kj-version.txt
|
||||||
|
# (line 1 = version tag, line 2 = tree SHA, line 3 = source repo URL).
|
||||||
|
# - It fetches the kj Go source by tag (reliable; the pinned tree SHA
|
||||||
|
# is kept for traceability with v1.28 — see kj-version.txt comments).
|
||||||
|
# - It builds CGO_ENABLED=0, asserts KJ-STATIC via `file(1)`, packages
|
||||||
|
# the binary into public.ecr.aws/lambda/python:3.12-al2023 at
|
||||||
|
# /opt/kj/kj (chmod 0555, sbx_user:1051), and pushes to ECR with tag
|
||||||
|
# v1.29.x-kj-<kj-source-sha>. The tag is validated against
|
||||||
|
# ^[a-zA-Z0-9._-]+$ before push (D-239).
|
||||||
|
#
|
||||||
|
# Secrets / env:
|
||||||
|
# AWS_ROLE_ARN — OIDC role to assume (id-token: write)
|
||||||
|
# NOVA_CODEARTIFACT_DOMAIN — optional; when set, CodeArtifact mode
|
||||||
|
# TWINE_USERNAME — fallback-index upload user
|
||||||
|
# TWINE_PASSWORD — fallback-index upload password
|
||||||
|
# TWINE_REPOSITORY_URL — fallback-index upload URL
|
||||||
|
# AWS_DEFAULT_REGION (optional) — defaults to us-east-1
|
||||||
|
# NOVA_ECR_REPO — ECR repository URI for the kj image
|
||||||
|
# (e.g. 581513795199.dkr.ecr.us-east-1.
|
||||||
|
# amazonaws.com/nova-kj)
|
||||||
|
name: nova-publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v1.29.*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # OIDC federation to AWS
|
||||||
|
contents: write # create the GitHub Release + upload artifacts
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-kj-image:
|
||||||
|
# KJ substrate — compile the kj Go binary static, package it into a
|
||||||
|
# public.ecr.aws/lambda/python:3.12-al2023 image at /opt/kj/kj, and
|
||||||
|
# push to ECR with tag v1.29.x-kj-<kj-source-sha> (D-239). Records
|
||||||
|
# image_uri + digest for the release body (REQ-354 criterion 4).
|
||||||
|
name: Build + push kj ECR image (KJ-STATIC, D-239)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image_uri: ${{ steps.ecr-push.outputs.image_uri }}
|
||||||
|
image_digest: ${{ steps.ecr-push.outputs.image_digest }}
|
||||||
|
image_tag: ${{ steps.ecr-push.outputs.image_tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
|
||||||
|
- name: Read kj version pin (platform/abac/kj-version.txt)
|
||||||
|
id: kj-ver
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
KJ_VERSION=$(sed -n '1p' platform/abac/kj-version.txt)
|
||||||
|
KJ_TREE_SHA=$(sed -n '2p' platform/abac/kj-version.txt)
|
||||||
|
KJ_REPO_URL=$(sed -n '3p' platform/abac/kj-version.txt)
|
||||||
|
echo "kj_version=${KJ_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "kj_tree_sha=${KJ_TREE_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "kj_repo_url=${KJ_REPO_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Pinned kj: version=${KJ_VERSION} tree_sha=${KJ_TREE_SHA} repo=${KJ_REPO_URL}"
|
||||||
|
|
||||||
|
- name: Fetch kj Go source at tag v0.0.3
|
||||||
|
env:
|
||||||
|
KJ_REPO_URL: ${{ steps.kj-ver.outputs.kj_repo_url }}
|
||||||
|
KJ_VERSION: ${{ steps.kj-ver.outputs.kj_version }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# The pinned tree SHA (line 2) 404s as a commit; the build
|
||||||
|
# fetches by tag, which dereferences to a real commit
|
||||||
|
# (verified: 924a6af2474523c4e27e3a826248c91c8fe1d1cf).
|
||||||
|
rm -rf kj-src
|
||||||
|
git clone --depth 1 --branch "${KJ_VERSION}" \
|
||||||
|
"${KJ_REPO_URL}" kj-src
|
||||||
|
|
||||||
|
- name: Build kj (CGO_ENABLED=0 — KJ-STATIC)
|
||||||
|
working-directory: kj-src
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Resolve the tagged commit SHA — this is the source SHA
|
||||||
|
# embedded in the ECR image tag (REQ-354 criterion 3).
|
||||||
|
KJ_SOURCE_SHA=$(git rev-parse HEAD)
|
||||||
|
echo "kj_source_sha=${KJ_SOURCE_SHA}" >> "$GITHUB_ENV"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -ldflags="-s -w" -o kj ./...
|
||||||
|
file kj
|
||||||
|
|
||||||
|
- name: Assert kj is statically linked (KJ-STATIC CI gate)
|
||||||
|
working-directory: kj-src
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# KJ-STATIC: file(1) MUST report `statically linked` and MUST
|
||||||
|
# NOT report `shared library`. Fail closed otherwise — this
|
||||||
|
# is the mechanical enforcement of KJ-STATIC (not human review).
|
||||||
|
FILE_OUT=$(file kj)
|
||||||
|
echo "$FILE_OUT"
|
||||||
|
case "$FILE_OUT" in
|
||||||
|
*statically\ linked*) ;;
|
||||||
|
*) echo "FAIL (KJ-STATIC): kj is not statically linked"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$FILE_OUT" in
|
||||||
|
*shared\ library*)
|
||||||
|
echo "FAIL (KJ-STATIC): kj links a shared library"; exit 1 ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
# readelf defense-in-depth: assert no NEEDED entries.
|
||||||
|
if readelf -d kj 2>/dev/null | grep -q NEEDED; then
|
||||||
|
echo "FAIL (KJ-STATIC): readelf -d reports NEEDED entries"; exit 1
|
||||||
|
fi
|
||||||
|
echo "KJ-STATIC assertion passed."
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
|
||||||
|
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||||
|
|
||||||
|
- name: Log in to ECR
|
||||||
|
env:
|
||||||
|
NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# NOVA_ECR_REPO is the full repo URI, e.g.
|
||||||
|
# 581513795199.dkr.ecr.us-east-1.amazonaws.com/nova-kj
|
||||||
|
REGISTRY=$(echo "$NOVA_ECR_REPO" | cut -d/ -f1)
|
||||||
|
aws ecr get-login-password --region "${AWS_REGION}" \
|
||||||
|
| docker login --username AWS --password-stdin "$REGISTRY"
|
||||||
|
|
||||||
|
- name: Build + push kj image to ECR (D-239)
|
||||||
|
id: ecr-push
|
||||||
|
env:
|
||||||
|
NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }}
|
||||||
|
KJ_SOURCE_SHA: ${{ env.kj_source_sha }}
|
||||||
|
working-directory: kj-src
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# D-239: ECR tags reject `+`; use `-` separator. The tag is
|
||||||
|
# v1.29.x-kj-<kj-source-sha> and is validated against
|
||||||
|
# ^[a-zA-Z0-9._-]+$ before push.
|
||||||
|
IMAGE_TAG="v1.29.x-kj-${KJ_SOURCE_SHA}"
|
||||||
|
if ! echo "$IMAGE_TAG" | grep -Eq '^[a-zA-Z0-9._-]+$'; then
|
||||||
|
echo "FAIL (D-239): invalid ECR tag: ${IMAGE_TAG}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
IMAGE_URI="${NOVA_ECR_REPO}:${IMAGE_TAG}"
|
||||||
|
echo "Pushing image: ${IMAGE_URI}"
|
||||||
|
# Stage the binary into a build context root.
|
||||||
|
rm -rf imgctx && mkdir -p imgctx/opt/kj
|
||||||
|
cp kj imgctx/opt/kj/kj
|
||||||
|
chmod 0555 imgctx/opt/kj/kj
|
||||||
|
printf '%s\n' \
|
||||||
|
'FROM public.ecr.aws/lambda/python:3.12-al2023' \
|
||||||
|
'COPY --chown=sbx_user:1051 --chmod=0555 opt/kj/kj /opt/kj/kj' \
|
||||||
|
> imgctx/Dockerfile
|
||||||
|
docker build -t "$IMAGE_URI" imgctx
|
||||||
|
docker push "$IMAGE_URI" >/tmp/docker-push.log 2>&1
|
||||||
|
cat /tmp/docker-push.log
|
||||||
|
# Extract the registry digest via `docker inspect` (the
|
||||||
|
# canonical source — push output wording varies by client).
|
||||||
|
IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \
|
||||||
|
"$IMAGE_URI" | sed 's/.*@//')
|
||||||
|
echo "image_uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image_digest=${IMAGE_DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Pushed ${IMAGE_URI} @ ${IMAGE_DIGEST}"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish wheel + Lambda layer + Lambda zip + Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-kj-image
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
|
||||||
|
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||||
|
|
||||||
|
- name: Install build + publish tools
|
||||||
|
run: pip install build twine
|
||||||
|
|
||||||
|
- name: Compute version from pyproject.toml
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION=$(python -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Nova version: $VERSION"
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
python -m build --wheel
|
||||||
|
ls -1 dist/
|
||||||
|
|
||||||
|
- name: Upload wheel to index (CodeArtifact default + fallback)
|
||||||
|
id: wheel
|
||||||
|
env:
|
||||||
|
NOVA_CODEARTIFACT_DOMAIN: ${{ secrets.NOVA_CODEARTIFACT_DOMAIN }}
|
||||||
|
TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
|
||||||
|
TWINE_REPOSITORY_URL: ${{ secrets.TWINE_REPOSITORY_URL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# CodeArtifact mode: log in to the domain's pypi repository.
|
||||||
|
if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]; then
|
||||||
|
echo "CodeArtifact mode: domain=$NOVA_CODEARTIFACT_DOMAIN repository=nova-pypi"
|
||||||
|
aws codeartifact login --tool twine \
|
||||||
|
--domain "$NOVA_CODEARTIFACT_DOMAIN" --repository nova-pypi
|
||||||
|
else
|
||||||
|
echo "Fallback-index mode: uploading to TWINE_REPOSITORY_URL"
|
||||||
|
if [ -z "$TWINE_REPOSITORY_URL" ] || [ -z "$TWINE_USERNAME" ] || [ -z "$TWINE_PASSWORD" ]; then
|
||||||
|
echo "FAIL: NOVA_CODEARTIFACT_DOMAIN is unset and one of TWINE_REPOSITORY_URL/TWINE_USERNAME/TWINE_PASSWORD is missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Idempotent upload: a re-run for the same version may hit
|
||||||
|
# "file already exists" on the index. Treat that as success.
|
||||||
|
# Capture both attempts' output so a genuine failure (auth,
|
||||||
|
# network, invalid package) is NOT masked as success — NFR-6
|
||||||
|
# requires the job to fail if the wheel publish fails.
|
||||||
|
if twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee /tmp/twine.log; then
|
||||||
|
echo "uploaded=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
# Retry once; the first attempt may have failed with a
|
||||||
|
# transient error OR with "already exists" (a re-run).
|
||||||
|
twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee -a /tmp/twine.log || true
|
||||||
|
if grep -qi "already exist" /tmp/twine.log 2>/dev/null; then
|
||||||
|
echo "Wheel already present on the index — treating as success (idempotent)."
|
||||||
|
echo "uploaded=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "FAIL: wheel upload failed (not an idempotent re-run)." >&2
|
||||||
|
cat /tmp/twine.log >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Lambda layer
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
rm -rf layer
|
||||||
|
mkdir -p layer/python
|
||||||
|
# Install the wheel we just built + the identity extras' deps
|
||||||
|
# so the layer carries argon2-cffi, cryptography, pyjwt.
|
||||||
|
pip install --target layer/python/ \
|
||||||
|
"dist/nova-${{ steps.ver.outputs.version }}-*.whl" \
|
||||||
|
argon2-cffi cryptography pyjwt
|
||||||
|
( cd layer && zip -r ../nova-cli-layer-v1.29.x.zip python/ )
|
||||||
|
ls -lh nova-cli-layer-v1.29.x.zip
|
||||||
|
|
||||||
|
- name: Publish Lambda layer
|
||||||
|
id: layer
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ARN=$(aws lambda publish-layer-version \
|
||||||
|
--layer-name nova-cli \
|
||||||
|
--zip-file fileb://nova-cli-layer-v1.29.x.zip \
|
||||||
|
--compatible-runtimes python3.12 \
|
||||||
|
--compatible-architectures x86_64 \
|
||||||
|
--description "nova-cli v${{ steps.ver.outputs.version }}" \
|
||||||
|
--query LayerVersionArn --output text)
|
||||||
|
echo "arn=$ARN" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Published Lambda layer: $ARN"
|
||||||
|
|
||||||
|
- name: Record SSM version↔ARN mapping (CAP-035)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name /nova/layer/nova-cli/version \
|
||||||
|
--value "${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}" \
|
||||||
|
--type String --overwrite
|
||||||
|
echo "SSM /nova/layer/nova-cli/version = ${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}"
|
||||||
|
|
||||||
|
- name: Build Lambda token-vend zip (nova-lambda-token-vend-v1.29.x.zip)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Package the nova-idp-token-vend Lambda handler (the dual-use
|
||||||
|
# module core/lambda/nova_idp_token_vend.py) plus the core/
|
||||||
|
# package modules it imports at runtime (core.policy_engine,
|
||||||
|
# core.abac_evaluator, core.kms_signing). The zip root mirrors
|
||||||
|
# the repo layout so `import core.lambda.nova_idp_token_vend`
|
||||||
|
# resolves inside the Lambda execution environment.
|
||||||
|
rm -rf lambdazip
|
||||||
|
mkdir -p lambdazip/core/lambda
|
||||||
|
cp core/lambda/__init__.py lambdazip/core/lambda/__init__.py
|
||||||
|
cp core/lambda/nova_idp_token_vend.py \
|
||||||
|
lambdazip/core/lambda/nova_idp_token_vend.py
|
||||||
|
# Carry the core/ modules the handler imports lazily.
|
||||||
|
cp core/__init__.py lambdazip/core/__init__.py 2>/dev/null || true
|
||||||
|
cp core/policy_engine.py lambdazip/core/policy_engine.py 2>/dev/null || true
|
||||||
|
cp core/abac_evaluator.py lambdazip/core/abac_evaluator.py 2>/dev/null || true
|
||||||
|
cp core/kms_signing.py lambdazip/core/kms_signing.py 2>/dev/null || true
|
||||||
|
( cd lambdazip && zip -r ../nova-lambda-token-vend-v1.29.x.zip . )
|
||||||
|
ls -lh nova-lambda-token-vend-v1.29.x.zip
|
||||||
|
|
||||||
|
- name: Compute SHA-256 of all release artifacts
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sha256sum nova-lambda-token-vend-v1.29.x.zip \
|
||||||
|
> /tmp/sha-lambda.txt
|
||||||
|
sha256sum nova-cli-layer-v1.29.x.zip \
|
||||||
|
> /tmp/sha-layer.txt
|
||||||
|
sha256sum dist/nova-${{ steps.ver.outputs.version }}-*.whl \
|
||||||
|
> /tmp/sha-wheel.txt
|
||||||
|
{
|
||||||
|
echo "## Artifact SHA-256 (REQ-354)"
|
||||||
|
echo ""
|
||||||
|
echo "### nova-lambda-token-vend-v1.29.x.zip"
|
||||||
|
echo '```'
|
||||||
|
cat /tmp/sha-lambda.txt
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### nova-cli-layer-v1.29.x.zip"
|
||||||
|
echo '```'
|
||||||
|
cat /tmp/sha-layer.txt
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### nova-${{ steps.ver.outputs.version }}-py3-none-any.whl"
|
||||||
|
echo '```'
|
||||||
|
cat /tmp/sha-wheel.txt
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### ECR kj image (REQ-354 criterion 3/4)"
|
||||||
|
echo "- URI: \`${{ needs.build-kj-image.outputs.image_uri }}\`"
|
||||||
|
echo "- digest: \`${{ needs.build-kj-image.outputs.image_digest }}\`"
|
||||||
|
echo "- tag: \`${{ needs.build-kj-image.outputs.image_tag }}\`"
|
||||||
|
echo ""
|
||||||
|
} > /tmp/release-body.md
|
||||||
|
echo "body_path=/tmp/release-body.md" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "--- Release body ---"
|
||||||
|
cat /tmp/release-body.md
|
||||||
|
|
||||||
|
- name: Create GitHub Release + attach artifacts (REQ-354)
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
# Use the pushed tag as the release tag.
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: Nova ${{ github.ref_name }}
|
||||||
|
body_path: ${{ steps.sha.outputs.body_path }}
|
||||||
|
files: |
|
||||||
|
nova-lambda-token-vend-v1.29.x.zip
|
||||||
|
nova-cli-layer-v1.29.x.zip
|
||||||
|
dist/nova-${{ steps.ver.outputs.version }}-*.whl
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Fail job if either publish failed (REQ-323 AC)
|
||||||
|
if: ${{ steps.wheel.outputs.uploaded != 'true' || steps.layer.outputs.arn == '' }}
|
||||||
|
run: |
|
||||||
|
echo "FAIL: wheel uploaded=${{ steps.wheel.outputs.uploaded }} layer_arn=${{ steps.layer.outputs.arn }}"
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
|
||||||
|
#
|
||||||
|
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
|
||||||
|
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
|
||||||
|
# uses the currently-active key. The rotation is best-effort + idempotent
|
||||||
|
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
|
||||||
|
# key propagates to the consumer's Actions secret store).
|
||||||
|
#
|
||||||
|
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
|
||||||
|
# (the root account 581513795199 can rotate its own keys — confirmed by the
|
||||||
|
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
|
||||||
|
# static-key path (no OIDC role-to-assume); the long-lived key rotates
|
||||||
|
# itself, which is the bootstrap-exception documented in §5.9.
|
||||||
|
#
|
||||||
|
# Forge coords (base URL / owner / consumer repo) are sourced from
|
||||||
|
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
|
||||||
|
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
|
||||||
|
# (REQ-230). The rotation script uploads the new key to the consumer's
|
||||||
|
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
|
||||||
|
# via secrets: inherit).
|
||||||
|
name: nova-rotate-aws-key
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *" # daily at 00:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rotate:
|
||||||
|
name: Rotate NOVA_AWS_* static key
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out Nova platform repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||||
|
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||||
|
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
|
||||||
|
- name: Install Python deps (boto3 for the rotation script)
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --break-system-packages --quiet boto3
|
||||||
|
|
||||||
|
- name: Run the key rotation script
|
||||||
|
env:
|
||||||
|
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
|
||||||
|
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
|
||||||
|
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
|
||||||
|
# Map the standard AWS_* exports onto the script's expected vars.
|
||||||
|
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||||
|
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
# Forge + consumer coords come from repository secrets (REQ-230 —
|
||||||
|
# no forge hostnames/orgs hardcoded in the synced workflow file).
|
||||||
|
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
|
||||||
|
# existing forge token as a one-time secret setup).
|
||||||
|
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
|
||||||
|
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
|
||||||
|
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
|
||||||
|
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||||
|
run: |
|
||||||
|
bash scripts/rotate_spike_key.sh
|
||||||
@@ -42,3 +42,6 @@ metrics/lifecycle/
|
|||||||
*.jks
|
*.jks
|
||||||
*.keystore.coverage
|
*.keystore.coverage
|
||||||
.coverage
|
.coverage
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
nova.egg-info/
|
||||||
|
|||||||
+51
-7
@@ -46,12 +46,28 @@ never import an engine directly — they go through the registry.
|
|||||||
|
|
||||||
## How to Write an Adapter
|
## How to Write an Adapter
|
||||||
|
|
||||||
### Terraform Adapter Extension
|
### Terraform Adapter Extension (stateless assembler — v1.11 rewrite)
|
||||||
|
|
||||||
1. Add a stack type → Terraform type mapping to `TYPE_MAP`.
|
> The adapter owns **no module content**. There is no `TYPE_MAP`, no
|
||||||
2. Add non-identity input mappings to `INPUT_MAP`.
|
> `INPUT_MAP`, no `OUTPUT_MAP`, and no per-type branch logic (all deleted
|
||||||
3. Add non-identity output mappings to `OUTPUT_MAP`.
|
> in the v1.11 rewrite — the 918-line monolith collapsed to a ~80-line
|
||||||
4. Add a specialized `_emit_resource` branch if the resource needs nested blocks (e.g. inline policies, rule sets).
|
> assembler). Engine-specific shape lives in each L1 module's own
|
||||||
|
> `terraform/` dir (`versions.tf`/`variables.tf`/`locals.tf`/`main.tf`/
|
||||||
|
> `outputs.tf`); the adapter only assembles them.
|
||||||
|
|
||||||
|
To extend the Terraform adapter, **do not edit the adapter** — instead:
|
||||||
|
|
||||||
|
1. Add an L1 module with a real `terraform/` dir (owning its resource
|
||||||
|
shape, nested HCL blocks, and defaults).
|
||||||
|
2. Register it in `modules/registry.json` under the module name with its
|
||||||
|
`terraform_dir` path. The adapter reads `registry.json` to find each
|
||||||
|
module's directory.
|
||||||
|
3. The adapter emits `module "<rid>" { source = "<path>" }` blocks at
|
||||||
|
the root, with resolved inputs + wired `ref:` refs between modules.
|
||||||
|
No type-specific translation lives in the adapter.
|
||||||
|
|
||||||
|
> If you find yourself reaching for a "TYPE_MAP"-style constant, the L1
|
||||||
|
> module is missing a piece — fix the module, not the adapter.
|
||||||
|
|
||||||
### Policy Adapter Pattern
|
### Policy Adapter Pattern
|
||||||
|
|
||||||
@@ -76,7 +92,7 @@ never import an engine directly — they go through the registry.
|
|||||||
|
|
||||||
## How to Test Adapters
|
## How to Test Adapters
|
||||||
|
|
||||||
- `tests/test_adapter.py` — Terraform adapter (`TYPE_MAP`, resource emission, refs, outputs).
|
- `tests/test_adapter.py` — Terraform adapter (stateless assembly: registry read, `module "<rid>" { source }` emission, `ref:` wiring, outputs). No `TYPE_MAP`/`INPUT_MAP` tests — the adapter owns no type mappings.
|
||||||
- `tests/test_checkov_adapter.py` — Checkov adapter.
|
- `tests/test_checkov_adapter.py` — Checkov adapter.
|
||||||
- `tests/test_wiz_adapter.py` — Wiz adapter.
|
- `tests/test_wiz_adapter.py` — Wiz adapter.
|
||||||
- `tests/test_kyverno_adapter.py` — Kyverno adapter.
|
- `tests/test_kyverno_adapter.py` — Kyverno adapter.
|
||||||
@@ -93,4 +109,32 @@ never import an engine directly — they go through the registry.
|
|||||||
3. Add the adapter's engine name to the `engine` enum in `schemas/policy_check_result.schema.json` if it is a policy adapter.
|
3. Add the adapter's engine name to the `engine` enum in `schemas/policy_check_result.schema.json` if it is a policy adapter.
|
||||||
4. Write a test (`tests/test_<name>_adapter.py`) plus a fixture (`tests/fixtures/<name>_fixture.json`).
|
4. Write a test (`tests/test_<name>_adapter.py`) plus a fixture (`tests/fixtures/<name>_fixture.json`).
|
||||||
5. Add it to `scripts/run_platform.sh` if it is invoked at runtime.
|
5. Add it to `scripts/run_platform.sh` if it is invoked at runtime.
|
||||||
6. Update this README.
|
6. Update this README.
|
||||||
|
|
||||||
|
## Consumers
|
||||||
|
|
||||||
|
The Terraform adapter compiles contract IR for consumer estates. The
|
||||||
|
first real consumer estate is now live:
|
||||||
|
|
||||||
|
| Consumer | Version | Environment | Account | Forge / Adapter | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `nova-blockchain-exchange` | v0.2 | dev | `581513795199` | inline adapter (see note below) | **live** (pilot apply `blkex-pilot-apply-v0.2`, 2026-08-19) |
|
||||||
|
|
||||||
|
### Forge adapter note (SPEC §10 Q1)
|
||||||
|
|
||||||
|
Forge Actions (the consumer's forge runtime) does **not** support
|
||||||
|
cross-repo `uses:` references — the forge rejects
|
||||||
|
`uses: <owner>/<repo>/.github/workflows/<file>@<ref>` with
|
||||||
|
`expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}`.
|
||||||
|
The consumer (`nova-blockchain-exchange`) therefore uses an **inline
|
||||||
|
adapter** in its `deploy.yml`: the workflow does `actions/checkout@v4`
|
||||||
|
on the consumer, then `actions/checkout@v4` `acdl/acdl` @ `ref: v1.25`
|
||||||
|
into `platform/`, and runs `bash platform/scripts/run_platform.sh ...`
|
||||||
|
directly — no `uses:` indirection.
|
||||||
|
|
||||||
|
The platform's own `.github/workflows/deploy.yml` (this repo) stays as
|
||||||
|
the **GitHub Actions reference implementation** — the reusable
|
||||||
|
`workflow_call` workflow used by GitHub-hosted consumers. The two
|
||||||
|
files share the same contract shape; the only declared difference is
|
||||||
|
the forge/runtime, not the stages or commands. See
|
||||||
|
`.ciagent/ARCHITECTURE.md` §12.8 for the live pilot-estate wiring.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Nova KyvernoJsonEngine (REQ-293, v1.25).
|
"""Nova KyvernoJsonEngine (REQ-293, v1.25; fixed v1.26 P3 W0.5).
|
||||||
|
|
||||||
Implements the ``PolicyEngine`` protocol (``core/policy_engine.py``)
|
Implements the ``PolicyEngine`` protocol (``core/policy_engine.py``)
|
||||||
by shelling to the ``kj`` CLI (``kyverno-json``). Translates native
|
by shelling to the ``kj`` CLI (``kyverno-json``). Translates native
|
||||||
@@ -12,9 +12,10 @@ distinguish from the K8s Kyverno adapter's ``KYVERNO_`` prefix.
|
|||||||
Severity (RESEARCH §2.6, G-Q10a): kyverno-json does not natively assign
|
Severity (RESEARCH §2.6, G-Q10a): kyverno-json does not natively assign
|
||||||
severities. Each Nova policy declares its severity via a
|
severities. Each Nova policy declares its severity via a
|
||||||
``metadata.annotations["nova.cloudinit.dev/severity"]`` field. The
|
``metadata.annotations["nova.cloudinit.dev/severity"]`` field. The
|
||||||
engine reads this annotation from the loaded policy YAML (not from the
|
engine reads this annotation from the loaded policy file (not from the
|
||||||
scan result — the result doesn't carry it) and applies it to every
|
scan result — the result carries the policy spec but the annotation is
|
||||||
result that policy produces. Default when absent: ``"info"``.
|
read here from disk) and applies it to every result that policy
|
||||||
|
produces. Default when absent: ``"info"``.
|
||||||
|
|
||||||
Graceful degradation (D-120): ``is_configured()`` returns ``False`` when
|
Graceful degradation (D-120): ``is_configured()`` returns ``False`` when
|
||||||
``which kj`` is absent → ``evaluate()`` returns a single SKIPPED PCR
|
``which kj`` is absent → ``evaluate()`` returns a single SKIPPED PCR
|
||||||
@@ -24,6 +25,37 @@ the binary.
|
|||||||
Defensive parsing: any kyverno-json output that doesn't match the
|
Defensive parsing: any kyverno-json output that doesn't match the
|
||||||
expected shape produces an ``error`` PCR, never an exception. The
|
expected shape produces an ``error`` PCR, never an exception. The
|
||||||
engine is read-only against a local policy dir + a temp payload file.
|
engine is read-only against a local policy dir + a temp payload file.
|
||||||
|
|
||||||
|
v1.26 P3 W0.5 fix — three substrate bugs uncovered once ``kj`` was
|
||||||
|
actually installed (the v1.25 test suite ``pytest.skip``-masked them):
|
||||||
|
|
||||||
|
1. **``.json`` policy files are not loaded by ``kj`` v0.0.3.** The
|
||||||
|
upstream policy loader (``pkg/policy/load.go``) uses
|
||||||
|
``fileinfo.IsYaml()`` which only matches ``.yaml``/``.yml``
|
||||||
|
extensions — ``.json`` files are silently skipped, yielding
|
||||||
|
``evaluating N resources against 0 policies``. Nova policies are
|
||||||
|
authored as ``.json`` (the ``TestPolicyFilesExist`` tests assert the
|
||||||
|
``.json`` filenames). Fix: ``evaluate()`` materializes a temp policy
|
||||||
|
dir that mirrors the source tree with every ``.json`` policy copied
|
||||||
|
to a ``.yaml`` twin (JSON is a valid YAML subset — verified against
|
||||||
|
``kj`` v0.0.3). The source ``.json`` files remain untouched.
|
||||||
|
|
||||||
|
2. **Bare-list output format.** ``kj scan --output json`` emits a bare
|
||||||
|
JSON list at the top level (NOT ``{"results": [...]}``). Each entry
|
||||||
|
has ``resource`` (the evaluated payload) + ``results`` (list of
|
||||||
|
per-policy result objects, each carrying ``policy.metadata.name``,
|
||||||
|
``rules[]`` with ``rule.name``, ``violations[]`` (present on fail),
|
||||||
|
``error`` (string, present on policy-evaluation error)). The v1.25
|
||||||
|
``_translate`` did ``out.get("results", [])`` on a dict — but
|
||||||
|
``out`` is a list → returned ``[]`` → emitted a single
|
||||||
|
``KJ_NO_RESULTS`` pass PCR. **This is why all failing fixtures showed
|
||||||
|
0 fails.** Fix: ``_translate`` handles list (v0.0.3) and dict
|
||||||
|
(future-proof) shapes.
|
||||||
|
|
||||||
|
3. **``validate`` wrapper + check syntax.** Documented in the policy
|
||||||
|
files themselves (see the W0.5 policy edits). The engine itself does
|
||||||
|
not enforce policy shape — it only translates ``kj`` output — so
|
||||||
|
this fix lives in the policy ``.json`` files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -98,39 +130,41 @@ def _load_policy_severities(policy_dir: Path) -> dict[str, str]:
|
|||||||
return severities
|
return severities
|
||||||
|
|
||||||
|
|
||||||
def _to_pcr(entry: dict, contract_id: str, severity: str) -> dict:
|
def _materialize_yaml_policy_dir(src: Path) -> tuple[Path, bool]:
|
||||||
"""Translate a kyverno-json scan result entry to a PCR dict."""
|
"""Mirror ``src`` (recursively) into a temp dir, copying every
|
||||||
policy_name = entry.get("policy", "") or "UNKNOWN"
|
``.json`` policy to a ``.yaml`` twin and copying ``.yaml``/``.yml``
|
||||||
rule_name = entry.get("rule", "") or ""
|
files verbatim. Returns ``(temp_dir, created)``.
|
||||||
rule_id = f"KJ_{policy_name}"
|
|
||||||
if rule_name:
|
``kj`` v0.0.3's policy loader (``pkg/policy/load.go``) only matches
|
||||||
rule_id = f"{rule_id}/{rule_name}"
|
``.yaml``/``.yml`` extensions — ``.json`` files are silently
|
||||||
result_raw = entry.get("result", "skip")
|
skipped. Nova policies are authored as ``.json`` (the
|
||||||
result = RESULT_MAP.get(str(result_raw).lower(), "error")
|
``TestPolicyFilesExist`` tests assert the ``.json`` filenames, so
|
||||||
message = entry.get("message", "") or ""
|
they cannot be renamed in-place). JSON is a valid YAML subset, so
|
||||||
resource = entry.get("resource", "")
|
a byte-for-byte copy with a ``.yaml`` extension loads cleanly.
|
||||||
if not resource and entry.get("name"):
|
|
||||||
kind = entry.get("kind", "")
|
``created`` is ``False`` when ``src`` contains no policy files at
|
||||||
ns = entry.get("namespace", "")
|
all (empty dir) — in that case the temp dir is still returned (the
|
||||||
resource = f"{kind}/{ns}/{entry.get('name')}" if kind else entry.get("name", "")
|
caller invokes ``kj`` against it and gets the no-results path).
|
||||||
return {
|
"""
|
||||||
"contractId": contract_id,
|
tmp = Path(tempfile.mkdtemp(prefix="nova-kj-pol-"))
|
||||||
"evaluatedAt": _iso8601_now(),
|
any_policy = False
|
||||||
"engine": "kyverno",
|
if src.is_dir():
|
||||||
"ruleId": rule_id,
|
for root, _dirs, files in os.walk(src):
|
||||||
"severity": severity,
|
rel = Path(root).relative_to(src)
|
||||||
"result": result,
|
dest_root = tmp / rel
|
||||||
"message": message,
|
dest_root.mkdir(parents=True, exist_ok=True)
|
||||||
"evidence": {
|
for fn in files:
|
||||||
"resource": resource,
|
if fn.startswith(".") or fn.startswith("_"):
|
||||||
"policy": policy_name,
|
continue
|
||||||
"rule": rule_name,
|
src_file = Path(root) / fn
|
||||||
"namespace": entry.get("namespace", ""),
|
if fn.endswith(".json"):
|
||||||
"kind": entry.get("kind", ""),
|
dest_file = dest_root / (fn.rsplit(".", 1)[0] + ".yaml")
|
||||||
"name": entry.get("name", ""),
|
shutil.copy2(src_file, dest_file)
|
||||||
},
|
any_policy = True
|
||||||
"resourceRef": resource,
|
elif fn.endswith((".yaml", ".yml")):
|
||||||
}
|
shutil.copy2(src_file, dest_root / fn)
|
||||||
|
any_policy = True
|
||||||
|
return tmp, any_policy
|
||||||
|
|
||||||
|
|
||||||
def _skipped_not_configured(contract_id: str) -> dict:
|
def _skipped_not_configured(contract_id: str) -> dict:
|
||||||
@@ -165,6 +199,23 @@ def _error_pcr(contract_id: str, message: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_results_pass(contract_id: str) -> dict:
|
||||||
|
"""No result entries — emit a single pass PCR so the confidence
|
||||||
|
signal's policy input is non-empty (a non-empty list of passes →
|
||||||
|
score 1.0)."""
|
||||||
|
return {
|
||||||
|
"contractId": contract_id,
|
||||||
|
"evaluatedAt": _iso8601_now(),
|
||||||
|
"engine": "kyverno",
|
||||||
|
"ruleId": "KJ_NO_RESULTS",
|
||||||
|
"severity": "info",
|
||||||
|
"result": "pass",
|
||||||
|
"message": "kyverno-json scan produced no result entries (all policies passed or no match).",
|
||||||
|
"evidence": {},
|
||||||
|
"resourceRef": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class KyvernoJsonEngine:
|
class KyvernoJsonEngine:
|
||||||
"""``PolicyEngine`` impl that shells to the ``kj`` CLI."""
|
"""``PolicyEngine`` impl that shells to the ``kj`` CLI."""
|
||||||
|
|
||||||
@@ -185,6 +236,9 @@ class KyvernoJsonEngine:
|
|||||||
f"kyverno-json policy dir not found: {policy_dir}",
|
f"kyverno-json policy dir not found: {policy_dir}",
|
||||||
)]
|
)]
|
||||||
severities = _load_policy_severities(policy_dir)
|
severities = _load_policy_severities(policy_dir)
|
||||||
|
# kj v0.0.3 only loads .yaml/.yml policy files. Mirror the tree
|
||||||
|
# to a temp dir with .json policies copied to .yaml twins.
|
||||||
|
yaml_dir, _any_policy = _materialize_yaml_policy_dir(policy_dir)
|
||||||
# Write payload to temp file (kj scan --payload expects a file path).
|
# Write payload to temp file (kj scan --payload expects a file path).
|
||||||
payload_tmp = tempfile.NamedTemporaryFile(
|
payload_tmp = tempfile.NamedTemporaryFile(
|
||||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||||
@@ -195,7 +249,7 @@ class KyvernoJsonEngine:
|
|||||||
payload_tmp.close()
|
payload_tmp.close()
|
||||||
cmd = [
|
cmd = [
|
||||||
kj, "scan",
|
kj, "scan",
|
||||||
"--policy", str(policy_dir),
|
"--policy", str(yaml_dir),
|
||||||
"--payload", payload_tmp.name,
|
"--payload", payload_tmp.name,
|
||||||
"--output", "json",
|
"--output", "json",
|
||||||
]
|
]
|
||||||
@@ -211,7 +265,7 @@ class KyvernoJsonEngine:
|
|||||||
f"kyverno-json scan exited {proc.returncode}: {proc.stderr[:200]}",
|
f"kyverno-json scan exited {proc.returncode}: {proc.stderr[:200]}",
|
||||||
)]
|
)]
|
||||||
try:
|
try:
|
||||||
out = json.loads(proc.stdout) if proc.stdout.strip() else {}
|
out = json.loads(proc.stdout) if proc.stdout.strip() else []
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return [_error_pcr(
|
return [_error_pcr(
|
||||||
contract_id,
|
contract_id,
|
||||||
@@ -223,38 +277,185 @@ class KyvernoJsonEngine:
|
|||||||
os.unlink(payload_tmp.name)
|
os.unlink(payload_tmp.name)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
shutil.rmtree(yaml_dir, ignore_errors=True)
|
||||||
|
|
||||||
def _translate(self, out: dict, contract_id: str,
|
def _translate(self, out: Any, contract_id: str,
|
||||||
severities: dict[str, str]) -> list[dict]:
|
severities: dict[str, str]) -> list[dict]:
|
||||||
results = out.get("results", []) if isinstance(out, dict) else []
|
# kj v0.0.3 emits a BARE JSON LIST at the top level: each entry
|
||||||
if not isinstance(results, list):
|
# has `resource` (the evaluated payload) + `results` (list of
|
||||||
results = []
|
# per-policy result objects). Future-proof: also accept the
|
||||||
|
# legacy {"results": [...]} dict shape.
|
||||||
|
if isinstance(out, list):
|
||||||
|
entries = out
|
||||||
|
elif isinstance(out, dict):
|
||||||
|
entries = out.get("results", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
entries = []
|
||||||
|
else:
|
||||||
|
entries = []
|
||||||
pcrs: list[dict] = []
|
pcrs: list[dict] = []
|
||||||
for entry in results:
|
for entry in entries:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
policy_name = entry.get("policy", "") or "UNKNOWN"
|
resource = entry.get("resource", {})
|
||||||
severity = severities.get(policy_name, SEVERITY_DEFAULT)
|
results = entry.get("results", [])
|
||||||
pcrs.append(_to_pcr(entry, contract_id, severity))
|
if not isinstance(results, list):
|
||||||
|
results = []
|
||||||
|
for pol_result in results:
|
||||||
|
if not isinstance(pol_result, dict):
|
||||||
|
continue
|
||||||
|
policy_obj = pol_result.get("policy", {}) or {}
|
||||||
|
policy_name = (
|
||||||
|
policy_obj.get("metadata", {}).get("name") if isinstance(policy_obj, dict)
|
||||||
|
else None
|
||||||
|
) or "UNKNOWN"
|
||||||
|
severity = severities.get(policy_name, SEVERITY_DEFAULT)
|
||||||
|
rules = pol_result.get("rules", [])
|
||||||
|
if not isinstance(rules, list):
|
||||||
|
rules = []
|
||||||
|
for rule_entry in rules:
|
||||||
|
if not isinstance(rule_entry, dict):
|
||||||
|
continue
|
||||||
|
rule_obj = rule_entry.get("rule", {}) or {}
|
||||||
|
rule_name = rule_obj.get("name", "") if isinstance(rule_obj, dict) else ""
|
||||||
|
rule_id = f"KJ_{policy_name}"
|
||||||
|
if rule_name:
|
||||||
|
rule_id = f"{rule_id}/{rule_name}"
|
||||||
|
violations = rule_entry.get("violations")
|
||||||
|
error_str = rule_entry.get("error")
|
||||||
|
if isinstance(violations, list) and violations:
|
||||||
|
# Fail: build a message from the violations' errors.
|
||||||
|
msg_parts: list[str] = []
|
||||||
|
for v in violations:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
continue
|
||||||
|
for err in v.get("errors", []) or []:
|
||||||
|
if not isinstance(err, dict):
|
||||||
|
continue
|
||||||
|
field = err.get("field", "")
|
||||||
|
detail = err.get("detail", "")
|
||||||
|
value = err.get("value", "")
|
||||||
|
msg_parts.append(
|
||||||
|
f"{field}: value={value!r} detail={detail}"
|
||||||
|
)
|
||||||
|
message = "; ".join(msg_parts) if msg_parts else "policy rule failed"
|
||||||
|
pcrs.append({
|
||||||
|
"contractId": contract_id,
|
||||||
|
"evaluatedAt": _iso8601_now(),
|
||||||
|
"engine": "kyverno",
|
||||||
|
"ruleId": rule_id,
|
||||||
|
"severity": severity,
|
||||||
|
"result": "fail",
|
||||||
|
"message": message,
|
||||||
|
"evidence": {
|
||||||
|
"resource": resource,
|
||||||
|
"policy": policy_name,
|
||||||
|
"rule": rule_name,
|
||||||
|
"violations": violations,
|
||||||
|
},
|
||||||
|
"resourceRef": _resource_ref(resource),
|
||||||
|
})
|
||||||
|
elif isinstance(error_str, str) and error_str:
|
||||||
|
# Policy-evaluation error (e.g. bad JMESPath).
|
||||||
|
pcrs.append({
|
||||||
|
"contractId": contract_id,
|
||||||
|
"evaluatedAt": _iso8601_now(),
|
||||||
|
"engine": "kyverno",
|
||||||
|
"ruleId": rule_id,
|
||||||
|
"severity": severity,
|
||||||
|
"result": "error",
|
||||||
|
"message": error_str,
|
||||||
|
"evidence": {
|
||||||
|
"resource": resource,
|
||||||
|
"policy": policy_name,
|
||||||
|
"rule": rule_name,
|
||||||
|
},
|
||||||
|
"resourceRef": _resource_ref(resource),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Pass: no violations, no error.
|
||||||
|
pcrs.append({
|
||||||
|
"contractId": contract_id,
|
||||||
|
"evaluatedAt": _iso8601_now(),
|
||||||
|
"engine": "kyverno",
|
||||||
|
"ruleId": rule_id,
|
||||||
|
"severity": severity,
|
||||||
|
"result": "pass",
|
||||||
|
"message": "",
|
||||||
|
"evidence": {
|
||||||
|
"resource": resource,
|
||||||
|
"policy": policy_name,
|
||||||
|
"rule": rule_name,
|
||||||
|
},
|
||||||
|
"resourceRef": _resource_ref(resource),
|
||||||
|
})
|
||||||
if not pcrs:
|
if not pcrs:
|
||||||
# No results — kyverno-json produced nothing (no match, or
|
pcrs.append(_no_results_pass(contract_id))
|
||||||
# all policies passed with no result entries). Emit a
|
|
||||||
# single pass PCR so the confidence signal's policy input
|
|
||||||
# is non-empty (a non-empty list of passes → score 1.0).
|
|
||||||
pcrs.append({
|
|
||||||
"contractId": contract_id,
|
|
||||||
"evaluatedAt": _iso8601_now(),
|
|
||||||
"engine": "kyverno",
|
|
||||||
"ruleId": "KJ_NO_RESULTS",
|
|
||||||
"severity": "info",
|
|
||||||
"result": "pass",
|
|
||||||
"message": "kyverno-json scan produced no result entries (all policies passed or no match).",
|
|
||||||
"evidence": {},
|
|
||||||
"resourceRef": "",
|
|
||||||
})
|
|
||||||
return pcrs
|
return pcrs
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_ref(resource: Any) -> str:
|
||||||
|
"""Best-effort resource ref from the evaluated payload."""
|
||||||
|
if isinstance(resource, dict):
|
||||||
|
for key in ("id", "name", "address"):
|
||||||
|
v = resource.get(key)
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Legacy _to_pcr kept for the existing TestToPcr unit tests ---
|
||||||
|
# (test_kyverno_json_engine.py::TestToPcr constructs flat `entry`
|
||||||
|
# dicts with `policy`/`rule`/`result`/`message`/`resource` keys and
|
||||||
|
# asserts the translated PCR shape. The production _translate path no
|
||||||
|
# longer calls this helper — it inlines the translation against the
|
||||||
|
# real kj v0.0.3 nested output — but the unit tests pin the helper's
|
||||||
|
# contract, so it stays.)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_pcr(entry: dict, contract_id: str, severity: str) -> dict:
|
||||||
|
"""Translate a flat kyverno-json scan result entry to a PCR dict.
|
||||||
|
|
||||||
|
Legacy shape (kept for unit-test backwards compatibility): the
|
||||||
|
entry is a flat dict with ``policy``/``rule``/``result``/``message``/
|
||||||
|
``resource`` string keys. The production ``_translate`` path no
|
||||||
|
longer calls this — it inlines translation against the real kj
|
||||||
|
v0.0.3 nested ``resource``+``results``+``rules`` shape — but the
|
||||||
|
``TestToPcr`` unit tests pin this contract.
|
||||||
|
"""
|
||||||
|
policy_name = entry.get("policy", "") or "UNKNOWN"
|
||||||
|
rule_name = entry.get("rule", "") or ""
|
||||||
|
rule_id = f"KJ_{policy_name}"
|
||||||
|
if rule_name:
|
||||||
|
rule_id = f"{rule_id}/{rule_name}"
|
||||||
|
result_raw = entry.get("result", "skip")
|
||||||
|
result = RESULT_MAP.get(str(result_raw).lower(), "error")
|
||||||
|
message = entry.get("message", "") or ""
|
||||||
|
resource = entry.get("resource", "")
|
||||||
|
if not resource and entry.get("name"):
|
||||||
|
kind = entry.get("kind", "")
|
||||||
|
ns = entry.get("namespace", "")
|
||||||
|
resource = f"{kind}/{ns}/{entry.get('name')}" if kind else entry.get("name", "")
|
||||||
|
return {
|
||||||
|
"contractId": contract_id,
|
||||||
|
"evaluatedAt": _iso8601_now(),
|
||||||
|
"engine": "kyverno",
|
||||||
|
"ruleId": rule_id,
|
||||||
|
"severity": severity,
|
||||||
|
"result": result,
|
||||||
|
"message": message,
|
||||||
|
"evidence": {
|
||||||
|
"resource": resource,
|
||||||
|
"policy": policy_name,
|
||||||
|
"rule": rule_name,
|
||||||
|
"namespace": entry.get("namespace", ""),
|
||||||
|
"kind": entry.get("kind", ""),
|
||||||
|
"name": entry.get("name", ""),
|
||||||
|
},
|
||||||
|
"resourceRef": resource,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -12,17 +12,16 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "require-id",
|
"name": "require-id",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "contract id is required",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"id": {
|
||||||
"check": {
|
"(regex_match('^[a-z][a-z0-9-]{2,5}$', @))": true
|
||||||
"id": "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,18 +12,17 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-unknown-fields",
|
"name": "no-unknown-fields",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "contract may only contain id, name, environment, infrastructure (schema-allowed fields)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"(length(keys(@)) == `4`)": true,
|
||||||
"check": {
|
"keys(@)": {
|
||||||
"(length(keys(@)) == `4`)": true,
|
"(contains(['id','name','environment','infrastructure'], @))": true
|
||||||
"keys(@)": "(contains(['id','name','environment','infrastructure'], @))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,17 +12,16 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "env-enum",
|
"name": "env-enum",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "contract.environment must be one of dev, qa, prod, dr",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"environment": {
|
||||||
"check": {
|
"(contains(['dev','qa','prod','dr'], @))": true
|
||||||
"environment": "(contains(['dev','qa','prod','dr'], @))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,17 +12,16 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "id-pattern",
|
"name": "id-pattern",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "contract.id must match ^[a-z][a-z0-9-]{2,5}$ (3-6 char operational acronym)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"id": {
|
||||||
"check": {
|
"(regex_match('^[a-z][a-z0-9-]{2,5}$', @))": true
|
||||||
"id": "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,17 +12,16 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "infra-min-1",
|
"name": "infra-min-1",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "contract.infrastructure must have at least one module entry",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"infrastructure": {
|
||||||
"check": {
|
"(length(keys(@)) > `0`)": true
|
||||||
"infrastructure": "(length(keys(@)) > `0`)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,19 +12,14 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-critical-fail",
|
"name": "no-critical-fail",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "No PolicyCheckResult in the merged list may have severity: critical + result: fail. The confidence_signal.py hard-override is the defense-in-depth behind this declarative rule (D-119).",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"(severity == 'critical' && result == 'fail')": false
|
||||||
"check": {
|
|
||||||
"~.[]": {
|
|
||||||
"(severity == 'critical' && result == 'fail')": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,28 +12,19 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-tagging-divergence",
|
"name": "no-tagging-divergence",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "For every resource, the Checkov NOVA_TAG_NAMING result and the kyverno-json KJ_REQUIRE_TAGGING_STANDARD result must agree. Divergence emits an error PCR (D-118, defense-in-depth against rule drift).",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"(ruleId == 'NOVA_TAG_NAMING' && result == 'fail')": false
|
||||||
"check": {
|
|
||||||
"~.[?(ruleId == 'NOVA_TAG_NAMING')]": {
|
|
||||||
"result->ckv_result": {},
|
|
||||||
"($ckv_result == 'fail')": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"check": {
|
|
||||||
"~.[?(ruleId == 'KJ_REQUIRE_TAGGING_STANDARD')]": {
|
|
||||||
"result->kj_result": {},
|
|
||||||
"($kj_result == 'fail')": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
}
|
{
|
||||||
|
"check": {
|
||||||
|
"(ruleId == 'KJ_REQUIRE_TAGGING_STANDARD' && result == 'fail')": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "json.kyverno.io/v1alpha1",
|
||||||
|
"kind": "ValidatingPolicy",
|
||||||
|
"metadata": {
|
||||||
|
"name": "no-placeholder-account",
|
||||||
|
"annotations": {
|
||||||
|
"nova.cloudinit.dev/severity": "critical",
|
||||||
|
"title.policy.kyverno.io": "Env does not use a placeholder AWS account id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "no-placeholder-account",
|
||||||
|
"assert": {
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"check": {
|
||||||
|
"(account_id == '000000000000')": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,36 +12,38 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-wildcard-action",
|
"name": "no-wildcard-action",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "IAM policy Action must not be '*' (ports CKV_AWS_1/40)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"planned_values": {
|
||||||
"check": {
|
"root_module": {
|
||||||
"planned_values.root_module.~.resources": {
|
"~.resources": {
|
||||||
"(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Action, '*'))": false
|
"(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Action, '*'))": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "no-wildcard-resource",
|
"name": "no-wildcard-resource",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "IAM policy Resource must not be '*' (ports CKV_AWS_1/40)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"planned_values": {
|
||||||
"check": {
|
"root_module": {
|
||||||
"planned_values.root_module.~.resources": {
|
"~.resources": {
|
||||||
"(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Resource, '*'))": false
|
"(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Resource, '*'))": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,19 +12,20 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-plaintext-db-password",
|
"name": "no-plaintext-db-password",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "aws_db_instance.password must not be a plaintext string (ports CKV_AWS_41/45/46)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"planned_values": {
|
||||||
"check": {
|
"root_module": {
|
||||||
"planned_values.root_module.~.resources": {
|
"~.resources": {
|
||||||
"(type == 'aws_db_instance' && contains(keys(values), 'password') && !contains(['${...}', ''], values.password))": false
|
"(type == 'aws_db_instance' && contains(keys(values), 'password') && !contains(['${...}', ''], values.password))": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,19 +12,20 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "kms-by-alias",
|
"name": "kms-by-alias",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "aws_kms_key resources should reference a customer-managed key alias, not inline key material (ports CKV_AWS_7/33)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"planned_values": {
|
||||||
"check": {
|
"root_module": {
|
||||||
"planned_values.root_module.~.resources": {
|
"~.resources": {
|
||||||
"(type == 'aws_kms_key' && !contains(keys(values), 'key_id') && !contains(keys(values), 'kms_key_id'))": false
|
"(type == 'aws_kms_key' && !contains(keys(values), 'key_id') && !contains(keys(values), 'kms_key_id'))": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,17 +12,14 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "no-duplicate-adapters",
|
"name": "no-duplicate-adapters",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "Each adapter must be registered exactly once (no duplicate adapter names in the capability inventory). Declarative mirror of core/regression_verify.py CAP-013.",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"(max(map(&length(@), values(group_by(adapters, &@)))) == `1`)": true
|
||||||
"check": {
|
|
||||||
"adapters": "(length(duplicates(@)) == `0`)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,19 +12,16 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "every-metric-has-status",
|
"name": "every-metric-has-status",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "Every metric in docs/METRICS.md must declare a status (grounded, derived, or deferred). Declarative mirror of core/regression_verify.py CAP-023.",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"~.metrics": {
|
||||||
"check": {
|
"(contains(['grounded','derived','deferred'], status))": true
|
||||||
"~.metrics": {
|
|
||||||
"(contains(['grounded','derived','deferred'], status))": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,22 +12,19 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "deck-has-4-beats",
|
"name": "deck-has-4-beats",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "The deck must have the 4-beat arc: Problem, Solution, Proof, Roadmap+Ask. Declarative mirror of core/regression_verify.py CAP-024.",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"deck": {
|
||||||
"check": {
|
"beats": {
|
||||||
"deck.beats": "(length(@) >= `4`)"
|
"(length(@) >= `4`)": true,
|
||||||
}
|
"(contains(@, 'Problem') && contains(@, 'Solution') && contains(@, 'Proof') && contains(@, 'Roadmap+Ask'))": true
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"check": {
|
|
||||||
"deck.beats": "(contains(@, 'Problem') && contains(@, 'Solution') && contains(@, 'Proof') && contains(@, 'Roadmap+Ask'))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "json.kyverno.io/v1alpha1",
|
||||||
|
"kind": "ValidatingPolicy",
|
||||||
|
"metadata": {
|
||||||
|
"name": "all-matches-committed",
|
||||||
|
"annotations": {
|
||||||
|
"nova.cloudinit.dev/severity": "critical",
|
||||||
|
"title.policy.kyverno.io": "All settlement matches are committed (finalized)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "all-matches-committed",
|
||||||
|
"assert": {
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"check": {
|
||||||
|
"(all_committed)": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,19 +13,16 @@
|
|||||||
{
|
{
|
||||||
"name": "no-public-ingress",
|
"name": "no-public-ingress",
|
||||||
"identifier": "id",
|
"identifier": "id",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "public_ingress: true is not allowed on any resource (v1.0 demo rule, now declarative)",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"~.resources": {
|
||||||
"check": {
|
"(inputs.public_ingress || `false`)": false
|
||||||
"~.resources": {
|
|
||||||
"(inputs.public_ingress || `false`)": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,43 +13,31 @@
|
|||||||
{
|
{
|
||||||
"name": "s3-encryption",
|
"name": "s3-encryption",
|
||||||
"identifier": "id",
|
"identifier": "id",
|
||||||
"match": {
|
"assert": {
|
||||||
"any": [
|
"all": [
|
||||||
{"type": "aws:s3:bucket"}
|
{
|
||||||
]
|
"check": {
|
||||||
},
|
"~.resources": {
|
||||||
"validate": {
|
"(type == 'aws:s3:bucket' && !(contains(keys(inputs), 'bucket_encryption') || contains(keys(inputs), 'kms_key_id')))": false
|
||||||
"message": "S3 buckets must declare encryption config (inputs.bucket_encryption or inputs.kms_key_id)",
|
|
||||||
"assert": {
|
|
||||||
"all": [
|
|
||||||
{
|
|
||||||
"check": {
|
|
||||||
"(contains(keys(inputs), 'bucket_encryption') || contains(keys(inputs), 'kms_key_id'))": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ebs-encryption",
|
"name": "ebs-encryption",
|
||||||
"identifier": "id",
|
"identifier": "id",
|
||||||
"match": {
|
"assert": {
|
||||||
"any": [
|
"all": [
|
||||||
{"type": "aws:ebs:volume"}
|
{
|
||||||
]
|
"check": {
|
||||||
},
|
"~.resources": {
|
||||||
"validate": {
|
"(type == 'aws:ebs:volume' && !(contains(keys(inputs), 'encrypted') || contains(keys(inputs), 'kms_key_id')))": false
|
||||||
"message": "EBS volumes must declare encryption (inputs.encrypted or inputs.kms_key_id)",
|
|
||||||
"assert": {
|
|
||||||
"all": [
|
|
||||||
{
|
|
||||||
"check": {
|
|
||||||
"(contains(keys(inputs), 'encrypted') || contains(keys(inputs), 'kms_key_id'))": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,22 +13,19 @@
|
|||||||
{
|
{
|
||||||
"name": "require-nova-tags",
|
"name": "require-nova-tags",
|
||||||
"identifier": "id",
|
"identifier": "id",
|
||||||
"validate": {
|
"assert": {
|
||||||
"message": "Every taggable resource must carry nova:owner, nova:contract, nova:environment, nova:cost-center tags",
|
"all": [
|
||||||
"assert": {
|
{
|
||||||
"all": [
|
"check": {
|
||||||
{
|
"~.resources": {
|
||||||
"check": {
|
"(contains(keys(inputs.tags || `{}`), 'nova:owner'))": true,
|
||||||
"~.resources": {
|
"(contains(keys(inputs.tags || `{}`), 'nova:contract'))": true,
|
||||||
"(contains(keys(tags || `[]`), 'nova:owner'))": true,
|
"(contains(keys(inputs.tags || `{}`), 'nova:environment'))": true,
|
||||||
"(contains(keys(tags || `[]`), 'nova:contract'))": true,
|
"(contains(keys(inputs.tags || `{}`), 'nova:cost-center'))": true
|
||||||
"(contains(keys(tags || `[]`), 'nova:environment'))": true,
|
|
||||||
"(contains(keys(tags || `[]`), 'nova:cost-center'))": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ def _module_name(resource):
|
|||||||
return resource.get("module", "").split("@")[0]
|
return resource.get("module", "").split("@")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_json(env_name, repo_root):
|
||||||
|
"""Load core/environments/<env_name>.json → dict (P03 W3, REQ-319).
|
||||||
|
|
||||||
|
Returns {} if the file is absent (the adapter falls back to the
|
||||||
|
computed state-bucket name). Sources env.state_backend.bucket +
|
||||||
|
env.account_id + env.region for the S3 backend block.
|
||||||
|
"""
|
||||||
|
env_path = os.path.join(repo_root, "core", "environments", f"{env_name}.json")
|
||||||
|
if not os.path.isfile(env_path):
|
||||||
|
return {}
|
||||||
|
with open(env_path, "r") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_state_bucket(env_json, region):
|
||||||
|
"""Resolve the S3 state-backend bucket name (P03 W3, REQ-319).
|
||||||
|
|
||||||
|
Precedence: (1) env.state_backend.bucket when present + non-empty;
|
||||||
|
(2) nova-tfstate-{account_id}-{region} from env.account_id + region
|
||||||
|
(backwards-compat); (3) nova-tfstate-581513795199-{region} when
|
||||||
|
account_id is absent (the only real account — bootstrap bucket).
|
||||||
|
The env JSON is authoritative; NOVA_AWS_ACCOUNT_ID is no longer
|
||||||
|
consulted for the bucket name.
|
||||||
|
"""
|
||||||
|
bucket = (env_json.get("state_backend") or {}).get("bucket")
|
||||||
|
if bucket:
|
||||||
|
return bucket
|
||||||
|
account_id = env_json.get("account_id") or "581513795199"
|
||||||
|
return f"nova-tfstate-{account_id}-{region}"
|
||||||
|
|
||||||
|
|
||||||
def _ref_expr(value, data_source_names=None, id_remap=None):
|
def _ref_expr(value, data_source_names=None, id_remap=None):
|
||||||
"""Translate `ref:<rid>.<output>` → `module.<rid>.<output>` (or
|
"""Translate `ref:<rid>.<output>` → `module.<rid>.<output>` (or
|
||||||
`data.terraform_remote_state.platform.outputs.<output>` for data
|
`data.terraform_remote_state.platform.outputs.<output>` for data
|
||||||
@@ -108,13 +139,20 @@ def adapt(stack_instance, out_dir):
|
|||||||
resources = stack_instance.get("resources", [])
|
resources = stack_instance.get("resources", [])
|
||||||
stack_outputs = stack_instance.get("outputs", {})
|
stack_outputs = stack_instance.get("outputs", {})
|
||||||
|
|
||||||
region = next((r["inputs"]["region"] for r in resources if "region" in r.get("inputs", {})), "us-east-1")
|
|
||||||
providers_tf = f'provider "aws" {{\n region = "{region}"\n}}\n'
|
|
||||||
|
|
||||||
stack_name = stack.get("name", "spike")
|
stack_name = stack.get("name", "spike")
|
||||||
environment = stack.get("environment", "dev")
|
environment = stack.get("environment", "dev")
|
||||||
account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
|
# P03 W3 (REQ-319): state backend bucket + account_id + region come
|
||||||
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
|
# from the env onboarding JSON (source of truth post-REQ-319). Bucket
|
||||||
|
# = env.state_backend.bucket when present (fallback to the computed
|
||||||
|
# nova-tfstate-{account_id}-{region} pattern for backwards compat).
|
||||||
|
env_json = _load_env_json(environment, repo_root)
|
||||||
|
region = env_json.get("region") or next(
|
||||||
|
(r["inputs"]["region"] for r in resources if "region" in r.get("inputs", {})),
|
||||||
|
"us-east-1",
|
||||||
|
)
|
||||||
|
state_bucket = _resolve_state_bucket(env_json, region)
|
||||||
|
providers_tf = f'provider "aws" {{\n region = "{region}"\n}}\n'
|
||||||
|
|
||||||
# State key is env-scoped (v1.24 REQ-287): the {environment} segment lets
|
# State key is env-scoped (v1.24 REQ-287): the {environment} segment lets
|
||||||
# the env-transition detect-and-destroy step target the PRIOR env's state
|
# the env-transition detect-and-destroy step target the PRIOR env's state
|
||||||
# without affecting the new env. No orphan path on environment promotion.
|
# without affecting the new env. No orphan path on environment promotion.
|
||||||
@@ -130,7 +168,7 @@ def adapt(stack_instance, out_dir):
|
|||||||
' backend "s3" {\n'
|
' backend "s3" {\n'
|
||||||
f' bucket = "{state_bucket}"\n'
|
f' bucket = "{state_bucket}"\n'
|
||||||
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
||||||
' region = "us-east-1"\n'
|
f' region = "{region}"\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
'}\n'
|
'}\n'
|
||||||
)
|
)
|
||||||
@@ -145,7 +183,7 @@ def adapt(stack_instance, out_dir):
|
|||||||
' config = {\n'
|
' config = {\n'
|
||||||
f' bucket = "{state_bucket}"\n'
|
f' bucket = "{state_bucket}"\n'
|
||||||
f' key = "{remote_state_key}"\n'
|
f' key = "{remote_state_key}"\n'
|
||||||
' region = "us-east-1"\n'
|
f' region = "{region}"\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
'}\n'
|
'}\n'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Nova ABAC evaluator for the token-vend Lambda (REQ-339, C-6.1, D-231).
|
||||||
|
|
||||||
|
Wraps :func:`core.policy_engine.get_engine` to evaluate the
|
||||||
|
``platform/abac/token-vend.policy`` kyverno-json ``ValidatingPolicy``
|
||||||
|
against a token-vend authorization payload and produce an allow/deny
|
||||||
|
decision with the policy SHA (D-231).
|
||||||
|
|
||||||
|
Payload shape (REQ-339, C-5.1)::
|
||||||
|
|
||||||
|
{
|
||||||
|
"subject": {"id": ..., "role": ..., "owner": ...},
|
||||||
|
"requested_claims": [<claim name>, ...], # C-5.1
|
||||||
|
"target_resource": {"type": ..., "id": ..., "owner": ..., "environment": ...},
|
||||||
|
"environment": "dev" | "qa" | "prod" | "dr",
|
||||||
|
"pat_jti": "<PAT jti>",
|
||||||
|
"policy_version": "<git SHA>"
|
||||||
|
}
|
||||||
|
|
||||||
|
Decision rule (C-6.1 fail-closed): **any** PCR with ``result == "fail"``
|
||||||
|
and ``severity == "critical"`` → ``allowed=False``. The caller (the
|
||||||
|
token-vend Lambda) is additionally required to fail closed when
|
||||||
|
``KyvernoJsonEngine.is_configured()`` returns ``False`` or when this
|
||||||
|
function raises — see ``tests/test_abac_fail_closed.py`` (the grill's
|
||||||
|
#1 finding, INV-17).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from core.policy_engine import get_engine
|
||||||
|
|
||||||
|
|
||||||
|
_POLICY_DIR = Path("platform/abac")
|
||||||
|
_POLICY_FILE = _POLICY_DIR / "token-vend.policy"
|
||||||
|
_CONTRACT_ID = "token-vend"
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_policy_dir(src_dir: Path) -> Tuple[Path, bool]:
|
||||||
|
"""Mirror ``src_dir`` to a temp dir, copying ``*.policy`` files to
|
||||||
|
``*.json`` twins (JSON is a valid kyverno-json policy format; the
|
||||||
|
``KyvernoJsonEngine`` only loads ``.json``/``.yaml``/``.yml``, and
|
||||||
|
Nova ABAC policies use the ``.policy`` extension per REQ-339, so a
|
||||||
|
byte-for-byte copy with a ``.json`` extension is required).
|
||||||
|
|
||||||
|
Returns ``(temp_dir, created)``; ``created`` is ``False`` when no
|
||||||
|
policy files were found. The caller is responsible for removing the
|
||||||
|
temp dir.
|
||||||
|
"""
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="nova-abac-pol-"))
|
||||||
|
any_policy = False
|
||||||
|
if src_dir.is_dir():
|
||||||
|
for entry in sorted(os.listdir(src_dir)):
|
||||||
|
if entry.startswith(".") or entry.startswith("_"):
|
||||||
|
continue
|
||||||
|
src_file = src_dir / entry
|
||||||
|
if not src_file.is_file():
|
||||||
|
continue
|
||||||
|
if entry.endswith(".policy"):
|
||||||
|
dest = tmp / (entry[: -len(".policy")] + ".json")
|
||||||
|
shutil.copy2(src_file, dest)
|
||||||
|
any_policy = True
|
||||||
|
elif entry.endswith((".json", ".yaml", ".yml")):
|
||||||
|
shutil.copy2(src_file, tmp / entry)
|
||||||
|
any_policy = True
|
||||||
|
return tmp, any_policy
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_sha() -> str:
|
||||||
|
"""Return the git SHA of the policy file (D-231).
|
||||||
|
|
||||||
|
Uses ``git rev-parse HEAD:platform/abac/token-vend.policy`` so the
|
||||||
|
SHA is stable across checkouts (blob SHA, not commit SHA). Falls
|
||||||
|
back to ``"unknown"`` when git is unavailable or the file is not
|
||||||
|
tracked (e.g. during local development before the first commit).
|
||||||
|
"""
|
||||||
|
repo_root = os.environ.get("NOVA_REPO_ROOT") or os.getcwd()
|
||||||
|
try:
|
||||||
|
sha = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD:platform/abac/token-vend.policy"],
|
||||||
|
cwd=repo_root,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
).strip()
|
||||||
|
return sha or "unknown"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_token_vend_policy(
|
||||||
|
payload: dict,
|
||||||
|
) -> Tuple[bool, list, str]:
|
||||||
|
"""Evaluate the token-vend ABAC policy against ``payload``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: the ABAC authorization payload (see module docstring).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(allowed, pcrs, policy_sha)`` where ``allowed`` is ``True``
|
||||||
|
iff no PCR has ``result == "fail"`` with ``severity ==
|
||||||
|
"critical"`` (C-6.1). ``pcrs`` is the raw list of
|
||||||
|
``PolicyCheckResult`` dicts from the engine. ``policy_sha`` is
|
||||||
|
the git blob SHA of the policy file (D-231).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: any engine error propagates — the caller MUST catch
|
||||||
|
and fail closed (403 ``abac_eval_failed``). This function
|
||||||
|
does NOT swallow errors: failing closed is the *caller's*
|
||||||
|
responsibility so the denial audit event is emitted at the
|
||||||
|
Lambda boundary with the right reason code.
|
||||||
|
"""
|
||||||
|
engine = get_engine()
|
||||||
|
# Nova ABAC policies use the `.policy` extension (REQ-339), but
|
||||||
|
# KyvernoJsonEngine only loads `.json`/`.yaml`/`.yml`. Materialize a
|
||||||
|
# temp dir with `.policy` → `.json` twins so the engine picks them
|
||||||
|
# up. The temp dir is removed in the `finally` block.
|
||||||
|
pol_dir, _ = _materialize_policy_dir(_POLICY_DIR)
|
||||||
|
try:
|
||||||
|
pcrs = engine.evaluate(payload, pol_dir, _CONTRACT_ID)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(pol_dir, ignore_errors=True)
|
||||||
|
allowed = not any(
|
||||||
|
p.get("result") == "fail" and str(p.get("severity", "")).lower() == "critical"
|
||||||
|
for p in pcrs
|
||||||
|
)
|
||||||
|
return allowed, pcrs, _policy_sha()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
with open(sys.argv[1]) as fh:
|
||||||
|
pl = json.load(fh)
|
||||||
|
else:
|
||||||
|
pl = json.loads(sys.stdin.read())
|
||||||
|
allowed, pcrs, sha = evaluate_token_vend_policy(pl)
|
||||||
|
print(json.dumps({"allowed": allowed, "policy_sha": sha, "pcrs": pcrs}, indent=2))
|
||||||
+14
-13
@@ -169,20 +169,21 @@ def check(env: str, evidence: dict) -> Tuple[bool, str]:
|
|||||||
return (True, f"{env}: all {len(concerns)} concern(s) pass")
|
return (True, f"{env}: all {len(concerns)} concern(s) pass")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def cli_main(argv) -> int:
|
||||||
|
"""Thin CLI entry (P1): nova attestation-matrix <env> [evidence.json]."""
|
||||||
import json
|
import json
|
||||||
if len(sys.argv) < 2:
|
if len(argv) < 2:
|
||||||
print("usage: attestation_matrix.py <env> [evidence.json]", file=sys.stderr)
|
print("usage: attestation_matrix <env> [evidence.json]", file=sys.stderr)
|
||||||
sys.exit(2)
|
return 2
|
||||||
_env = sys.argv[1]
|
_env = argv[1]
|
||||||
_evidence = {}
|
_evidence = {}
|
||||||
if len(sys.argv) >= 3 and os.path.isfile(sys.argv[2]):
|
if len(argv) >= 3 and os.path.isfile(argv[2]):
|
||||||
with open(sys.argv[2]) as f:
|
with open(argv[2]) as f:
|
||||||
_evidence = json.load(f)
|
_evidence = json.load(f)
|
||||||
ok, reason = check(_env, _evidence)
|
ok, reason = check(_env, _evidence)
|
||||||
if ok:
|
print(f"ATTESTATION PASS: {reason}") if ok else print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
||||||
print(f"ATTESTATION PASS: {reason}")
|
return 0 if ok else 1
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
if __name__ == "__main__":
|
||||||
sys.exit(1)
|
sys.exit(cli_main(sys.argv))
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Nova credential store — ``~/.nova/credentials.json`` (C-7.3, REQ-344).
|
||||||
|
|
||||||
|
Stores the OIDC token + PAT metadata (jti, exp, type) ONLY — **NOT the
|
||||||
|
raw PAT** (C-7.3). The file is 0600. "Most recent wins" (D-226 Q5):
|
||||||
|
``active_credential_jti`` points at the most-recently-stored credential.
|
||||||
|
|
||||||
|
Shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"active_credential_jti": "<jti>",
|
||||||
|
"credentials": [
|
||||||
|
{"jti": ..., "type": "developer_pat"|"nova_oidc_token",
|
||||||
|
"exp": <epoch>, "token": "<oidc jwt>", "stored_at": <epoch>}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def credentials_path() -> Path:
|
||||||
|
return Path(os.environ.get("NOVA_CREDENTIALS_FILE")
|
||||||
|
or os.path.expanduser("~/.nova/credentials.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
payload = {"event": event_type, **fields}
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def store_credential(
|
||||||
|
jti: str,
|
||||||
|
cred_type: str,
|
||||||
|
exp: int,
|
||||||
|
oidc_token: str,
|
||||||
|
path: Optional[Path] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Store an OIDC token + PAT metadata (NOT the raw PAT, C-7.3). 0600."""
|
||||||
|
p = path or credentials_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {"active_credential_jti": jti, "credentials": []}
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
data = {"active_credential_jti": jti, "credentials": []}
|
||||||
|
creds = data.get("credentials", []) or []
|
||||||
|
# Replace any existing entry with the same jti.
|
||||||
|
creds = [c for c in creds if c.get("jti") != jti]
|
||||||
|
import time
|
||||||
|
creds.append({
|
||||||
|
"jti": jti, "type": cred_type, "exp": exp,
|
||||||
|
"token": oidc_token, "stored_at": int(time.time()),
|
||||||
|
})
|
||||||
|
data["credentials"] = creds
|
||||||
|
data["active_credential_jti"] = jti
|
||||||
|
p.write_text(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||||
|
_emit_audit("auth.login", jti=jti, type=cred_type)
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials(path: Optional[Path] = None) -> dict:
|
||||||
|
"""Load the credentials file (or ``{}`` if absent)."""
|
||||||
|
p = path or credentials_path()
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def active_credential(path: Optional[Path] = None) -> Optional[dict]:
|
||||||
|
"""Return the active credential dict (or ``None``)."""
|
||||||
|
data = load_credentials(path)
|
||||||
|
active_jti = data.get("active_credential_jti")
|
||||||
|
for c in data.get("credentials", []) or []:
|
||||||
|
if c.get("jti") == active_jti:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def emit_status_audit(path: Optional[Path] = None) -> dict:
|
||||||
|
"""Emit ``auth.status`` audit + return the credentials data."""
|
||||||
|
data = load_credentials(path)
|
||||||
|
_emit_audit("auth.status", active_jti=data.get("active_credential_jti"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def emit_revoke_audit(jti: str) -> None:
|
||||||
|
_emit_audit("auth.revoke", jti=jti)
|
||||||
+46
-17
@@ -144,6 +144,7 @@ def compute(contract_id: str, environment: str,
|
|||||||
penalty = 0.0
|
penalty = 0.0
|
||||||
policy_input = inputs.get("policy")
|
policy_input = inputs.get("policy")
|
||||||
pcrs = policy_input if isinstance(policy_input, list) else []
|
pcrs = policy_input if isinstance(policy_input, list) else []
|
||||||
|
critical_override = False
|
||||||
for pcr in pcrs:
|
for pcr in pcrs:
|
||||||
if not isinstance(pcr, dict):
|
if not isinstance(pcr, dict):
|
||||||
continue
|
continue
|
||||||
@@ -152,20 +153,31 @@ def compute(contract_id: str, environment: str,
|
|||||||
sev = pcr.get("severity")
|
sev = pcr.get("severity")
|
||||||
p = PENALTY.get(sev, 0.0)
|
p = PENALTY.get(sev, 0.0)
|
||||||
if p is None:
|
if p is None:
|
||||||
return Signal(0.0, "block", per_input,
|
# Critical PCR hard override: score = 0, band = block.
|
||||||
reasons + [f"CRITICAL_OVERRIDE:{pcr.get('ruleId','?')}"])
|
# Do NOT early-return — fall through to the event emission
|
||||||
|
# block below so the SPEC §5.8 evidence stream
|
||||||
|
# (confidence.computed -> ai.decision.made -> ...) is complete
|
||||||
|
# even on a critical override (REQ-318: a critical PCR is a
|
||||||
|
# confidence-driven escalation and must carry escalation_reason).
|
||||||
|
reasons.append(f"CRITICAL_OVERRIDE:{pcr.get('ruleId','?')}")
|
||||||
|
critical_override = True
|
||||||
|
break
|
||||||
penalty += p
|
penalty += p
|
||||||
|
|
||||||
score = max(0.0, min(1.0, base - penalty))
|
if critical_override:
|
||||||
threshold = THRESHOLDS[environment]
|
score = 0.0
|
||||||
if score >= threshold:
|
|
||||||
band = "pass"
|
|
||||||
elif score < threshold - 0.10:
|
|
||||||
band = "block"
|
band = "block"
|
||||||
else:
|
else:
|
||||||
band = "warn"
|
score = max(0.0, min(1.0, base - penalty))
|
||||||
if environment == "dev" and band == "warn":
|
threshold = THRESHOLDS[environment]
|
||||||
band = "block"
|
if score >= threshold:
|
||||||
|
band = "pass"
|
||||||
|
elif score < threshold - 0.10:
|
||||||
|
band = "block"
|
||||||
|
else:
|
||||||
|
band = "warn"
|
||||||
|
if environment == "dev" and band == "warn":
|
||||||
|
band = "block"
|
||||||
signal = Signal(score, band, per_input, reasons)
|
signal = Signal(score, band, per_input, reasons)
|
||||||
|
|
||||||
# Emit nova.confidence.computed + nova.ai.decision.made events (D-122).
|
# Emit nova.confidence.computed + nova.ai.decision.made events (D-122).
|
||||||
@@ -184,6 +196,17 @@ def compute(contract_id: str, environment: str,
|
|||||||
"human_override": band == "block",
|
"human_override": band == "block",
|
||||||
"threshold": THRESHOLDS[environment],
|
"threshold": THRESHOLDS[environment],
|
||||||
}
|
}
|
||||||
|
# REQ-318 (SPEC §5.8): on a `block` band, carry escalation_reason.
|
||||||
|
# In v1.26 the only value is "confidence" — a block is always
|
||||||
|
# confidence-driven (the score fell below threshold OR a critical
|
||||||
|
# PCR fired a hard override). Future milestones may add "policy"
|
||||||
|
# (a critical PCR that is not confidence-scored); leave the door
|
||||||
|
# open but only emit "confidence" now. On pass/warn bands the
|
||||||
|
# field is ABSENT (escalation_reason is only meaningful on a
|
||||||
|
# block — it is the Post-Pilot Human Escalation Frequency
|
||||||
|
# denominator).
|
||||||
|
if band == "block":
|
||||||
|
decision_data["escalation_reason"] = "confidence"
|
||||||
decision_event = make_event("nova.ai.decision.made", run_id, environment, decision_data,
|
decision_event = make_event("nova.ai.decision.made", run_id, environment, decision_data,
|
||||||
contract_id=contract_id, actor_type="confidence-gate",
|
contract_id=contract_id, actor_type="confidence-gate",
|
||||||
actor_id="confidence_signal")
|
actor_id="confidence_signal")
|
||||||
@@ -195,12 +218,18 @@ def compute(contract_id: str, environment: str,
|
|||||||
return signal
|
return signal
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def cli_main(argv) -> int:
|
||||||
if len(sys.argv) < 3:
|
"""Thin CLI entry (P1): nova confidence <inputs.json> <environment>."""
|
||||||
print("usage: confidence_signal.py <inputs.json> <environment>", file=sys.stderr)
|
if len(argv) < 3:
|
||||||
sys.exit(2)
|
print("usage: confidence <inputs.json> <environment>", file=sys.stderr)
|
||||||
env = sys.argv[2]
|
return 2
|
||||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
env = argv[2]
|
||||||
|
with open(argv[1], "r", encoding="utf-8") as fh:
|
||||||
inputs = json.load(fh)
|
inputs = json.load(fh)
|
||||||
sig = compute("cli", env, inputs)
|
sig = compute("cli", env, inputs)
|
||||||
print(json.dumps(asdict(sig), indent=2))
|
print(json.dumps(asdict(sig), indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(cli_main(sys.argv))
|
||||||
+98
-4
@@ -1,4 +1,4 @@
|
|||||||
"""Environment helper (D-108, REQ-159, REQ-164).
|
"""Environment helper (D-108, REQ-159, REQ-164, REQ-330).
|
||||||
|
|
||||||
During the Nova rebrand transition window (P2–P4), `get_env` read
|
During the Nova rebrand transition window (P2–P4), `get_env` read
|
||||||
`NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5
|
`NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5
|
||||||
@@ -9,14 +9,27 @@ During the Nova rebrand transition window (P2–P4), `get_env` read
|
|||||||
`.env.secrets` shell export in `scripts/run_platform.sh` and the Python
|
`.env.secrets` shell export in `scripts/run_platform.sh` and the Python
|
||||||
parser in `core/regression_verify.py`) were updated to NOVA-only in P5
|
parser in `core/regression_verify.py`) were updated to NOVA-only in P5
|
||||||
(the G-106 dual-read contract was retired with the fallback).
|
(the G-106 dual-read contract was retired with the fallback).
|
||||||
|
|
||||||
|
P2 (REQ-330): `synthesize_local_env(contract_path, environment)` produces
|
||||||
|
a purely synthetic local env dict (account_id placeholder, region
|
||||||
|
"local", no real AWS resources) from a contract YAML. Mirrors the shape
|
||||||
|
of core/environments/*.json (validates against
|
||||||
|
schemas/environment.schema.json) so `nova apply --local` can run the
|
||||||
|
contract resolver + Terraform adapter without provisioning cloud
|
||||||
|
resources. This is the local-tier counterpart of
|
||||||
|
core/onboarding.py:generate_env_file() (the request-path binding
|
||||||
|
generator).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
__all__ = ["get_env"]
|
import yaml
|
||||||
|
|
||||||
|
__all__ = ["get_env", "synthesize_local_env"]
|
||||||
|
|
||||||
|
|
||||||
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
@@ -28,4 +41,85 @@ def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
|||||||
val = os.environ.get(f"NOVA_{name}")
|
val = os.environ.get(f"NOVA_{name}")
|
||||||
if val:
|
if val:
|
||||||
return val
|
return val
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Default confidence thresholds per environment name (mirrors the schema
|
||||||
|
# description: dev 0.50, qa 0.75, prod 0.90, dr 0.95). Used by
|
||||||
|
# synthesize_local_env so the synthetic env matches the real env semantics.
|
||||||
|
_DEFAULT_THRESHOLDS: Dict[str, float] = {
|
||||||
|
"dev": 0.50,
|
||||||
|
"qa": 0.75,
|
||||||
|
"prod": 0.90,
|
||||||
|
"dr": 0.95,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_local_env(
|
||||||
|
contract_path: str,
|
||||||
|
environment: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Synthesize a local env dict from a contract YAML (REQ-330).
|
||||||
|
|
||||||
|
Reads the contract YAML (``yaml.safe_load``), derives a placeholder
|
||||||
|
environment binding that ``nova apply --local`` can use WITHOUT
|
||||||
|
provisioning real AWS resources. The produced dict:
|
||||||
|
|
||||||
|
- ``name`` — the environment name (from the arg or the contract's
|
||||||
|
``environment`` field, defaulting to ``"dev"``).
|
||||||
|
- ``account_id`` — ``"000000000000"`` (the schema-allowed placeholder
|
||||||
|
for an unbound environment; real account id filled by the platform).
|
||||||
|
- ``region`` — ``"local"`` (the local-tier sentinel; never a real
|
||||||
|
AWS region).
|
||||||
|
- ``state_backend`` — ``{bucket: "local-tfstate", lock_table:
|
||||||
|
"local-locks"}`` (local state; LocalS3StateBackend rewrites the
|
||||||
|
terraform backend to ``backend "local"`` using the stack name as
|
||||||
|
the state path, so no S3 bucket is used).
|
||||||
|
- ``network`` — a local RFC1918 CIDR + a single fake AZ.
|
||||||
|
- ``runner_role_arn`` — a placeholder ARN for the local tier.
|
||||||
|
- ``autonomy`` — ``"full"`` (the local tier is autonomous).
|
||||||
|
- ``confidence_threshold`` — the per-env default (0.50 for dev).
|
||||||
|
|
||||||
|
The dict mirrors the shape of ``core/environments/*.json`` and
|
||||||
|
validates against ``schemas/environment.schema.json``. No cloud
|
||||||
|
provisioning occurs — purely synthetic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
contract_path: Path to the contract YAML file.
|
||||||
|
environment: Optional environment name override (defaults to the
|
||||||
|
contract's ``environment`` field, or ``"dev"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The synthetic local env dict.
|
||||||
|
"""
|
||||||
|
contract_path_obj = Path(contract_path)
|
||||||
|
contract: Dict[str, Any] = {}
|
||||||
|
if contract_path_obj.is_file():
|
||||||
|
with open(contract_path_obj) as fh:
|
||||||
|
contract = yaml.safe_load(fh) or {}
|
||||||
|
|
||||||
|
env_name = environment or contract.get("environment", "dev")
|
||||||
|
stack_name = contract.get("id", env_name)
|
||||||
|
threshold = _DEFAULT_THRESHOLDS.get(env_name, 0.50)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": env_name,
|
||||||
|
"description": (
|
||||||
|
f"Synthetic local-tier environment for contract '{stack_name}' "
|
||||||
|
f"(environment={env_name}). No real AWS resources — generated "
|
||||||
|
f"by core.env.synthesize_local_env (REQ-330) for nova apply --local."
|
||||||
|
),
|
||||||
|
"account_id": "000000000000",
|
||||||
|
"region": "local",
|
||||||
|
"state_backend": {
|
||||||
|
"bucket": "local-tfstate",
|
||||||
|
"lock_table": "local-locks",
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"vpc_cidr": "10.250.0.0/16",
|
||||||
|
"azs": ["local-a"],
|
||||||
|
},
|
||||||
|
"runner_role_arn": "arn:aws:iam::000000000000:role/local-runner",
|
||||||
|
"autonomy": "full",
|
||||||
|
"confidence_threshold": threshold,
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "dev",
|
"name": "dev",
|
||||||
"description": "Default platform-managed dev environment for onboarding demos.",
|
"description": "Default platform-managed dev environment for onboarding demos.",
|
||||||
"account_id": "000000000000",
|
"account_id": "581513795199",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"state_backend": {
|
"state_backend": {
|
||||||
"bucket": "acdl-dev-state",
|
"bucket": "nova-tfstate-581513795199-us-east-1",
|
||||||
"lock_table": "acdl-dev-locks"
|
"lock_table": "acdl-dev-locks"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"account_id": "000000000000",
|
"account_id": "000000000000",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"state_backend": {
|
"state_backend": {
|
||||||
"bucket": "acdl-dr-state",
|
"bucket": "nova-tfstate-000000000000-us-east-1",
|
||||||
"lock_table": "acdl-dr-locks"
|
"lock_table": "acdl-dr-locks"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"account_id": "000000000000",
|
"account_id": "000000000000",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"state_backend": {
|
"state_backend": {
|
||||||
"bucket": "acdl-prod-state",
|
"bucket": "nova-tfstate-000000000000-us-east-1",
|
||||||
"lock_table": "acdl-prod-locks"
|
"lock_table": "acdl-prod-locks"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"account_id": "000000000000",
|
"account_id": "000000000000",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"state_backend": {
|
"state_backend": {
|
||||||
"bucket": "acdl-qa-state",
|
"bucket": "nova-tfstate-000000000000-us-east-1",
|
||||||
"lock_table": "acdl-qa-locks"
|
"lock_table": "acdl-qa-locks"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Nova init scaffolding logic (P1, REQ-325).
|
||||||
|
|
||||||
|
Creates .nova/ directory structure + secrets-exclusion .gitignore lines
|
||||||
|
in the current working directory. nova/init.py delegates here so the
|
||||||
|
subcommand stays thin (≤50 lines, ≤3 functions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SECRETS_IGNORE_LINES = (
|
||||||
|
"~/.nova/credentials.json",
|
||||||
|
".nova/credentials.json",
|
||||||
|
"*.pem",
|
||||||
|
"*.key",
|
||||||
|
".env",
|
||||||
|
".env.*",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_gitignore(root: Path, force: bool) -> None:
|
||||||
|
gi = root / ".gitignore"
|
||||||
|
existing = gi.read_text().splitlines() if gi.is_file() else []
|
||||||
|
additions = [ln for ln in SECRETS_IGNORE_LINES if ln not in existing]
|
||||||
|
if not additions:
|
||||||
|
return
|
||||||
|
blob = gi.read_text() if gi.is_file() else ""
|
||||||
|
if blob and not blob.endswith("\n"):
|
||||||
|
blob += "\n"
|
||||||
|
blob += "\n".join(additions) + "\n"
|
||||||
|
gi.write_text(blob)
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold(root: Path | None = None, force: bool = False) -> int:
|
||||||
|
"""Create .nova/ + .nova/contract.yml.attestations/ + .gitignore lines."""
|
||||||
|
root = root or Path.cwd()
|
||||||
|
nova_dir = root / ".nova"
|
||||||
|
attest_dir = nova_dir / "contract.yml.attestations"
|
||||||
|
if nova_dir.exists() and not force:
|
||||||
|
print(f"refusing: {nova_dir} already exists (use --force to overwrite)")
|
||||||
|
return 1
|
||||||
|
nova_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
attest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_ensure_gitignore(root, force)
|
||||||
|
print(f"scaffolded: {nova_dir} (+ {attest_dir.name}/, .gitignore secrets)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(scaffold())
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""JWS-from-PAT key derivation + symmetric attestation (REQ-332, C-5.2).
|
||||||
|
|
||||||
|
C-5.2 grill fix: the "public key derivable from the PAT" acceptance
|
||||||
|
criterion is re-interpreted as a SYMMETRIC scheme. The PAT (Personal
|
||||||
|
Access Token) is the shared secret; the JWS signing key AND the
|
||||||
|
verification key are both derived from the PAT via the same HKDF-SHA256
|
||||||
|
KDF. The JWS uses HMAC-SHA256 (HS256) — a symmetric MAC, not an
|
||||||
|
asymmetric signature.
|
||||||
|
|
||||||
|
Key derivation (NIST SP 800-56C / RFC 5869):
|
||||||
|
key = HKDF-SHA256(
|
||||||
|
input_key_material = PAT.encode(),
|
||||||
|
salt = b"nova-local-attestation",
|
||||||
|
info = b"jws-signing-key",
|
||||||
|
length = 32,
|
||||||
|
)
|
||||||
|
|
||||||
|
The resulting 32-byte key is used both to sign (sign_attestation) and to
|
||||||
|
verify (verify_attestation). Anyone holding the PAT can derive the same
|
||||||
|
key and verify the attestation; without the PAT, the HMAC cannot be
|
||||||
|
forged. This satisfies INV-14..17:
|
||||||
|
|
||||||
|
- INV-14: the signing key is derived from the PAT (no separate key
|
||||||
|
material; no long-lived private key on disk).
|
||||||
|
- INV-15: the key never leaves the derivation (it is recomputed from
|
||||||
|
the PAT on each sign/verify call; not cached, not persisted).
|
||||||
|
- INV-16: the salt + info are fixed constants binding the key to the
|
||||||
|
"nova-local-attestation / jws-signing-key" purpose (key separation).
|
||||||
|
- INV-17: tamper detection via the HMAC verification (verify_attestation
|
||||||
|
raises on any signature mismatch).
|
||||||
|
|
||||||
|
The JWS is the compact serialization:
|
||||||
|
b64url(header).b64url(payload).b64url(signature)
|
||||||
|
where header = {"alg":"HS256","typ":"JWT"}, payload = the JWT claims
|
||||||
|
(the attestation payload dict), and signature = HMAC-SHA256(key,
|
||||||
|
b64url(header) + "." + b64url(payload)).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"derive_signing_key",
|
||||||
|
"sign_attestation",
|
||||||
|
"verify_attestation",
|
||||||
|
"JWSValidationError",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fixed KDF parameters (INV-16: key separation — binds the derived key to
|
||||||
|
# the nova-local-attestation / jws-signing-key purpose).
|
||||||
|
_KDF_SALT = b"nova-local-attestation"
|
||||||
|
_KDF_INFO = b"jws-signing-key"
|
||||||
|
_KDF_LENGTH = 32 # 256-bit key for HMAC-SHA256
|
||||||
|
|
||||||
|
# JWS header for HS256 (symmetric HMAC-SHA256).
|
||||||
|
_JWS_HEADER = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
|
||||||
|
|
||||||
|
class JWSValidationError(Exception):
|
||||||
|
"""Raised when a JWS attestation fails verification (signature mismatch,
|
||||||
|
malformed token, or wrong PAT)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
"""RFC 7515 base64url encoding WITHOUT padding (JWS compact form)."""
|
||||||
|
import base64
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(segment: str) -> bytes:
|
||||||
|
"""RFC 7515 base64url decoding (re-adds stripped padding)."""
|
||||||
|
import base64
|
||||||
|
pad = "=" * (-len(segment) % 4)
|
||||||
|
return base64.urlsafe_b64decode(segment + pad)
|
||||||
|
|
||||||
|
|
||||||
|
def _hkdf_sha256(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||||
|
"""HKDF-SHA256 (RFC 5869).
|
||||||
|
|
||||||
|
Prefers cryptography.hazmat.primitives.kdf.hkdf.HKDF (the cryptography
|
||||||
|
extra); falls back to a hashlib-based implementation if cryptography
|
||||||
|
is unavailable (so the module works in a minimal Lambda runtime).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=length,
|
||||||
|
salt=salt,
|
||||||
|
info=info,
|
||||||
|
)
|
||||||
|
return hkdf.derive(input_key_material)
|
||||||
|
except ImportError: # pragma: no cover - fallback path
|
||||||
|
return _hkdf_sha256_hashlib(input_key_material, salt, info, length)
|
||||||
|
|
||||||
|
|
||||||
|
def _hkdf_sha256_hashlib(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||||
|
"""RFC 5869 HKDF-SHA256 using only hashlib + hmac (fallback)."""
|
||||||
|
# Extract: PRK = HMAC-SHA256(salt, IKM)
|
||||||
|
prk = hmac.new(salt, input_key_material, hashlib.sha256).digest()
|
||||||
|
# Expand: T(i) = HMAC-SHA256(PRK, T(i-1) | info | i)
|
||||||
|
okm = b""
|
||||||
|
t = b""
|
||||||
|
block = 0
|
||||||
|
while len(okm) < length:
|
||||||
|
block += 1
|
||||||
|
t = hmac.new(prk, t + info + bytes([block]), hashlib.sha256).digest()
|
||||||
|
okm += t
|
||||||
|
return okm[:length]
|
||||||
|
|
||||||
|
|
||||||
|
def derive_signing_key(pat: str) -> bytes:
|
||||||
|
"""Derive the 32-byte symmetric JWS signing key from a PAT.
|
||||||
|
|
||||||
|
HKDF-SHA256(PAT.encode(), salt=b'nova-local-attestation',
|
||||||
|
info=b'jws-signing-key', length=32).
|
||||||
|
|
||||||
|
The same PAT always yields the same key (deterministic); the key is
|
||||||
|
never cached or persisted (INV-15 — recomputed on each call).
|
||||||
|
"""
|
||||||
|
if not isinstance(pat, str) or not pat:
|
||||||
|
raise ValueError("pat must be a non-empty string")
|
||||||
|
return _hkdf_sha256(
|
||||||
|
input_key_material=pat.encode("utf-8"),
|
||||||
|
salt=_KDF_SALT,
|
||||||
|
info=_KDF_INFO,
|
||||||
|
length=_KDF_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_attestation(payload: Dict[str, Any], pat: str) -> str:
|
||||||
|
"""Produce a compact JWS (HS256) for the attestation payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: the JWT claims (the attestation payload dict).
|
||||||
|
pat: the Personal Access Token (shared secret).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The compact JWS string: b64url(header).b64url(payload).b64url(signature).
|
||||||
|
The header is {"alg":"HS256","typ":"JWT"}; the payload is the
|
||||||
|
JSON-encoded claims; the signature is HMAC-SHA256(key, header.payload).
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("payload must be a dict")
|
||||||
|
key = derive_signing_key(pat)
|
||||||
|
header_segment = _b64url_encode(
|
||||||
|
json.dumps(_JWS_HEADER, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
)
|
||||||
|
payload_segment = _b64url_encode(
|
||||||
|
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
)
|
||||||
|
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||||
|
signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||||
|
signature_segment = _b64url_encode(signature)
|
||||||
|
return f"{header_segment}.{payload_segment}.{signature_segment}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_attestation(jws: str, pat: str) -> Dict[str, Any]:
|
||||||
|
"""Verify a compact JWS (HS256) attestation and return the payload.
|
||||||
|
|
||||||
|
Derives the same key from the PAT, recomputes the HMAC, and compares
|
||||||
|
in constant time. Raises JWSValidationError on:
|
||||||
|
- malformed JWS (not 3 segments, bad base64, bad JSON)
|
||||||
|
- signature mismatch (tampering or wrong PAT)
|
||||||
|
- wrong header (alg != HS256)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jws: the compact JWS string from sign_attestation.
|
||||||
|
pat: the Personal Access Token (shared secret).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decoded payload dict (the JWT claims) on success.
|
||||||
|
"""
|
||||||
|
if not isinstance(jws, str) or not jws:
|
||||||
|
raise JWSValidationError("jws must be a non-empty string")
|
||||||
|
parts = jws.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise JWSValidationError(f"malformed JWS: expected 3 segments, got {len(parts)}")
|
||||||
|
header_segment, payload_segment, signature_segment = parts
|
||||||
|
|
||||||
|
# Decode + validate the header.
|
||||||
|
try:
|
||||||
|
header = json.loads(_b64url_decode(header_segment))
|
||||||
|
except (ValueError, json.JSONDecodeError) as e:
|
||||||
|
raise JWSValidationError(f"malformed JWS header: {e}") from e
|
||||||
|
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||||
|
raise JWSValidationError(
|
||||||
|
f"unsupported JWS alg: expected HS256, got {header.get('alg')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recompute the signature with the key derived from the PAT.
|
||||||
|
key = derive_signing_key(pat)
|
||||||
|
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||||
|
expected_signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||||
|
actual_signature = _b64url_decode(signature_segment)
|
||||||
|
if not hmac.compare_digest(expected_signature, actual_signature):
|
||||||
|
raise JWSValidationError(
|
||||||
|
"JWS signature verification failed (tampered token or wrong PAT)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decode + return the payload.
|
||||||
|
try:
|
||||||
|
payload = json.loads(_b64url_decode(payload_segment))
|
||||||
|
except (ValueError, json.JSONDecodeError) as e:
|
||||||
|
raise JWSValidationError(f"malformed JWS payload: {e}") from e
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise JWSValidationError("JWS payload is not a JSON object")
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""KMS-signed JWT issuance for the Nova IdP (REQ-337, REQ-336).
|
||||||
|
|
||||||
|
Signs OIDC tokens with an AWS KMS asymmetric key (``ECC_NIST_P256``,
|
||||||
|
``ECDSA_SHA_256`` → JWS ``ES256``) and exposes the public key as a JWK
|
||||||
|
for the JWKS endpoint (REQ-338).
|
||||||
|
|
||||||
|
## DER → raw ECDSA conversion (the #1 gotcha, RESEARCH §5)
|
||||||
|
|
||||||
|
KMS ``sign()`` returns a **DER-encoded** ASN.1 ECDSA signature. JWS
|
||||||
|
(RFC 7515 §3.1.3) requires the **raw** ``r‖s`` concatenation, each
|
||||||
|
coordinate 32 bytes big-endian. :func:`der_to_raw_ecdsa` performs the
|
||||||
|
conversion via ``cryptography``'s ``decode_dss_signature``. This is the
|
||||||
|
core of REQ-337 and is verified by the CAP-037 round-trip test.
|
||||||
|
|
||||||
|
## Lazy boto3
|
||||||
|
|
||||||
|
``boto3.client("kms")`` is constructed lazily so the module imports
|
||||||
|
without AWS creds (mirrors ``nova_idp_auth.py``). Tests inject a mock
|
||||||
|
client via :func:`set_kms_client_for_testing`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
|
EllipticCurvePublicKey,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
|
||||||
|
|
||||||
|
# Default KMS key alias for Nova OIDC signing (REQ-337).
|
||||||
|
DEFAULT_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
|
||||||
|
_kms_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_kms_client():
|
||||||
|
"""Lazy boto3 KMS client singleton (mirrors nova_idp_auth.py)."""
|
||||||
|
global _kms_client
|
||||||
|
if _kms_client is None:
|
||||||
|
_kms_client = boto3.client("kms")
|
||||||
|
return _kms_client
|
||||||
|
|
||||||
|
|
||||||
|
def set_kms_client_for_testing(client: Any) -> None:
|
||||||
|
"""Inject a mock KMS client for tests (no real AWS calls)."""
|
||||||
|
global _kms_client
|
||||||
|
_kms_client = client
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url(data: bytes) -> str:
|
||||||
|
"""Base64url encode without padding (RFC 7515 §2)."""
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def der_to_raw_ecdsa(der_sig: bytes, coord_len: int = 32) -> bytes:
|
||||||
|
"""Convert a DER-encoded ECDSA signature to raw ``r‖s`` (JWS format).
|
||||||
|
|
||||||
|
KMS returns DER; JWS requires raw ``r‖s`` concatenation, each
|
||||||
|
coordinate ``coord_len`` bytes big-endian (32 for P-256, 48 for
|
||||||
|
P-384). Uses ``cryptography``'s ``decode_dss_signature`` to parse
|
||||||
|
the DER, then zero-pads each integer to ``coord_len``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if a coordinate does not fit in ``coord_len`` bytes
|
||||||
|
(the integer is larger than the curve allows — indicates a
|
||||||
|
malformed signature or wrong ``coord_len``).
|
||||||
|
"""
|
||||||
|
r, s = decode_dss_signature(der_sig)
|
||||||
|
if r.bit_length() > coord_len * 8 or s.bit_length() > coord_len * 8:
|
||||||
|
raise ValueError(
|
||||||
|
f"ECDSA coordinate does not fit in {coord_len} bytes "
|
||||||
|
f"(r={r.bit_length()} bits, s={s.bit_length()} bits)"
|
||||||
|
)
|
||||||
|
return r.to_bytes(coord_len, "big") + s.to_bytes(coord_len, "big")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_jwt(claims: dict, key_id: str = DEFAULT_KEY_ID) -> str:
|
||||||
|
"""Build + sign a JWT with KMS (REQ-337, REQ-336).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
claims: the JWT claims payload (``sub, aud, iss, exp, iat, jti,
|
||||||
|
roles`` per REQ-336, plus ``typ`` for PATs).
|
||||||
|
key_id: the KMS key ID or alias (default
|
||||||
|
``alias/nova-oidc-signing``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The compact JWS (``header.payload.signature``), ``ES256``,
|
||||||
|
with the signature in raw ``r‖s`` form (DER→raw converted).
|
||||||
|
"""
|
||||||
|
header = {"alg": "ES256", "typ": "JWT", "kid": key_id}
|
||||||
|
signing_input = (
|
||||||
|
_b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode())
|
||||||
|
+ "."
|
||||||
|
+ _b64url(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode())
|
||||||
|
)
|
||||||
|
resp = _get_kms_client().sign(
|
||||||
|
KeyId=key_id,
|
||||||
|
Message=signing_input.encode("ascii"),
|
||||||
|
MessageType="RAW",
|
||||||
|
SigningAlgorithm="ECDSA_SHA_256",
|
||||||
|
)
|
||||||
|
der_sig = resp["Signature"]
|
||||||
|
raw_sig = der_to_raw_ecdsa(der_sig)
|
||||||
|
return signing_input + "." + _b64url(raw_sig)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwk(key_id: str = DEFAULT_KEY_ID) -> dict:
|
||||||
|
"""Fetch the KMS public key and return it as a JWK (REQ-338).
|
||||||
|
|
||||||
|
Calls ``kms.get_public_key`` → DER SPKI → ``cryptography``'s
|
||||||
|
``load_der_public_key`` → JWK ``{"kty":"EC","crv":"P-256","kid":...,
|
||||||
|
"x":...,"y":...}``. The ``x``/``y`` are base64url-encoded
|
||||||
|
big-endian 32-byte coordinates.
|
||||||
|
"""
|
||||||
|
resp = _get_kms_client().get_public_key(KeyId=key_id)
|
||||||
|
pub = load_der_public_key(resp["PublicKey"])
|
||||||
|
if not isinstance(pub, EllipticCurvePublicKey):
|
||||||
|
raise ValueError(
|
||||||
|
f"KMS public key is not an EC key (got {type(pub).__name__})"
|
||||||
|
)
|
||||||
|
nums = pub.public_numbers()
|
||||||
|
# P-256 coordinates are 32 bytes big-endian.
|
||||||
|
x = nums.x.to_bytes(32, "big")
|
||||||
|
y = nums.y.to_bytes(32, "big")
|
||||||
|
return {
|
||||||
|
"kty": "EC",
|
||||||
|
"crv": "P-256",
|
||||||
|
"kid": key_id,
|
||||||
|
"x": _b64url(x),
|
||||||
|
"y": _b64url(y),
|
||||||
|
"alg": "ES256",
|
||||||
|
"use": "sig",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if "--print-jwk" in sys.argv:
|
||||||
|
print(json.dumps(get_jwk(), indent=2))
|
||||||
|
else:
|
||||||
|
print("usage: python3 -m core.kms_signing --print-jwks", file=sys.stderr)
|
||||||
@@ -457,65 +457,149 @@ def _onboard_consumer(payload):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_action(payload, event=None):
|
||||||
|
"""Shared business-logic dispatch for the contract ingestor (REQ-329).
|
||||||
|
|
||||||
|
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||||
|
(``cli_main`` / ``__main__``) call this function so the two paths share
|
||||||
|
a single source of truth for action routing, contract validation, the
|
||||||
|
DynamoDB write, and error reporting (NFR-7 — dual-use, single source).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: the decoded action envelope dict
|
||||||
|
``{ consumerRepo, contractId, contract, environment, action }``.
|
||||||
|
event: the raw Lambda Function-URL event (used for IAM caller
|
||||||
|
identity validation). When ``None`` (the CLI path), the identity
|
||||||
|
check uses the ``NOVA_LAMBDA_LOCAL_BYPASS`` env var — CLI invocations
|
||||||
|
are local-only and do not carry an IAM principal.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The action result dict (e.g. ``{status, contractId, action, ...}``)
|
||||||
|
on success. Raises ``ValueError`` for validation failures and other
|
||||||
|
exceptions for downstream errors — the caller is responsible for
|
||||||
|
mapping these to the appropriate status code / exit code.
|
||||||
|
"""
|
||||||
|
action = payload.get("action", "submit_contract")
|
||||||
|
# Validate caller identity against the payload (P1-2). The CLI path
|
||||||
|
# passes event=None; the fail-closed check honours the local bypass.
|
||||||
|
_validate_caller_identity(event or {}, payload)
|
||||||
|
if action == "submit_contract":
|
||||||
|
# Validate required fields up front for a clean 400.
|
||||||
|
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
||||||
|
if field not in payload:
|
||||||
|
raise ValueError(f"missing field: {field}")
|
||||||
|
result = _submit_contract(payload)
|
||||||
|
elif action == "report_error":
|
||||||
|
result = _report_error(payload)
|
||||||
|
elif action == "validate_change_request":
|
||||||
|
result = _validate_change_request(payload)
|
||||||
|
elif action == "onboard_consumer":
|
||||||
|
result = _onboard_consumer(payload)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown action: {action}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _to_http_response(result_or_error):
|
||||||
|
"""Map a dispatch_action result / exception to a Lambda HTTP response.
|
||||||
|
|
||||||
|
Shared error→status mapping so both Lambda + CLI paths interpret errors
|
||||||
|
identically (REQ-329 dual-use).
|
||||||
|
"""
|
||||||
|
if isinstance(result_or_error, Exception):
|
||||||
|
msg = str(result_or_error)
|
||||||
|
if isinstance(result_or_error, ValueError):
|
||||||
|
if "missing IAM caller identity" in msg:
|
||||||
|
return {"statusCode": 401, "body": json.dumps({"error": msg})}
|
||||||
|
return {"statusCode": 400, "body": json.dumps({"error": msg})}
|
||||||
|
return {"statusCode": 500, "body": json.dumps({"error": msg})}
|
||||||
|
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||||
|
|
||||||
|
|
||||||
def lambda_handler(event, context):
|
def lambda_handler(event, context):
|
||||||
"""AWS Lambda handler entry point.
|
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||||
|
|
||||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||||
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
||||||
|
Parses the Lambda-specific envelope then delegates to the shared
|
||||||
|
``dispatch_action`` business logic.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = event.get("body", "{}")
|
body = event.get("body", "{}")
|
||||||
if isinstance(body, str):
|
payload = json.loads(body) if isinstance(body, str) else body
|
||||||
payload = json.loads(body)
|
result = dispatch_action(payload, event=event)
|
||||||
else:
|
return _to_http_response(result)
|
||||||
payload = body
|
|
||||||
action = payload.get("action", "submit_contract")
|
|
||||||
# Validate caller identity against the payload (P1-2).
|
|
||||||
_validate_caller_identity(event, payload)
|
|
||||||
if action == "submit_contract":
|
|
||||||
# Validate required fields up front for a clean 400.
|
|
||||||
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
|
||||||
if field not in payload:
|
|
||||||
return {
|
|
||||||
"statusCode": 400,
|
|
||||||
"body": json.dumps({"error": f"missing field: {field}"}),
|
|
||||||
}
|
|
||||||
result = _submit_contract(payload)
|
|
||||||
elif action == "report_error":
|
|
||||||
result = _report_error(payload)
|
|
||||||
elif action == "validate_change_request":
|
|
||||||
result = _validate_change_request(payload)
|
|
||||||
elif action == "onboard_consumer":
|
|
||||||
result = _onboard_consumer(payload)
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"statusCode": 400,
|
|
||||||
"body": json.dumps({"error": f"unknown action: {action}"}),
|
|
||||||
}
|
|
||||||
return {"statusCode": 200, "body": json.dumps(result)}
|
|
||||||
except ValueError as e:
|
|
||||||
# P10 (REQ-174): identity failures are 401, field validation is 400.
|
|
||||||
if "missing IAM caller identity" in str(e):
|
|
||||||
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
|
|
||||||
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
|
||||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
return _to_http_response(e)
|
||||||
|
|
||||||
|
|
||||||
# --- CLI: --check-readiness (D-133, REQ-218) ---------------------------
|
def cli_main(argv=None):
|
||||||
# Invoked as: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
"""CLI entry point for the contract ingestor (REQ-329 dual-use).
|
||||||
# Delegates to core.submission_readiness.check_readiness() and prints the
|
|
||||||
# structured ReadinessResult. Exits 0 if ready, 1 if not.
|
Usage:
|
||||||
|
python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||||
|
python3 -m core.lambda.contract_ingestor --dispatch-stdin < <payload.json>
|
||||||
|
|
||||||
|
Parses the CLI-specific input (a JSON file path or stdin) then delegates
|
||||||
|
to the shared ``dispatch_action`` business logic — the same path as the
|
||||||
|
Lambda handler. Returns a process exit code (0 success, 1 validation
|
||||||
|
error, 2 internal error).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
# The --dispatch flag consumes the next positional arg as a payload path;
|
||||||
|
# --dispatch-stdin reads the payload from stdin.
|
||||||
|
if "--dispatch-stdin" in raw:
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
elif "--dispatch" in raw:
|
||||||
|
idx = raw.index("--dispatch")
|
||||||
|
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||||
|
if not path:
|
||||||
|
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(path) as fh:
|
||||||
|
payload = json.loads(fh.read())
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Usage: python3 -m core.lambda.contract_ingestor --dispatch <payload.json>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
result = dispatch_action(payload, event=None)
|
||||||
|
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
except ValueError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 1
|
||||||
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
|
sys.stderr.write(f"internal error: {e}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI: --check-readiness (D-133, REQ-218) + --dispatch (REQ-329) ----
|
||||||
|
# Invoked as:
|
||||||
|
# python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||||
|
# python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||||
|
# The --check-readiness path delegates to core.submission_readiness; the
|
||||||
|
# --dispatch path is the dual-use CLI entry (REQ-329) that calls the same
|
||||||
|
# dispatch_action() as the Lambda handler.
|
||||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
import sys
|
import sys
|
||||||
if "--check-readiness" in sys.argv:
|
if "--check-readiness" in sys.argv:
|
||||||
sys.path.insert(
|
sys.path.insert(
|
||||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
)
|
)
|
||||||
from core.submission_readiness import cli_main
|
from core.submission_readiness import cli_main as _readiness_cli
|
||||||
|
|
||||||
# Strip the --check-readiness flag; pass the file path.
|
# Strip the --check-readiness flag; pass the file path.
|
||||||
rest = [a for a in sys.argv[1:] if a != "--check-readiness"]
|
rest = [a for a in sys.argv[1:] if a != "--check-readiness"]
|
||||||
sys.exit(cli_main(["check-readiness"] + rest))
|
sys.exit(_readiness_cli(["check-readiness"] + rest))
|
||||||
|
elif "--dispatch" in sys.argv or "--dispatch-stdin" in sys.argv:
|
||||||
|
sys.exit(cli_main())
|
||||||
else:
|
else:
|
||||||
print("Usage: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>")
|
print(
|
||||||
|
"Usage: python3 -m core.lambda.contract_ingestor "
|
||||||
|
"--check-readiness <submission.json> | --dispatch <payload.json>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"""Nova IdP auth Lambda — sign-up / sign-in / session (REQ-333, REQ-334).
|
||||||
|
|
||||||
|
Invoked via a Function URL (IAM auth) by the Nova CLI and consumer
|
||||||
|
pipelines. Mirrors the ``contract_ingestor.py`` pattern: lazy
|
||||||
|
``boto3.resource`` DynamoDB singleton, env-var table names,
|
||||||
|
``NOVA_LAMBDA_LOCAL_BYPASS`` for local testing, ``__main__`` CLI block
|
||||||
|
for dual-use (REQ-329).
|
||||||
|
|
||||||
|
## Argon2id password hashing (REQ-334, D-228, C-7.2)
|
||||||
|
|
||||||
|
Passwords are hashed with Argon2id via ``argon2-cffi``:
|
||||||
|
|
||||||
|
PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
|
||||||
|
|
||||||
|
These are the OWASP minimum parameters (t=3, m=65536 KiB, p=1).
|
||||||
|
Lambda memory **MUST be ≥ 512 MB** (Argon2id memory_cost ~64 MiB +
|
||||||
|
runtime overhead).
|
||||||
|
|
||||||
|
**D-228 (amended) — fail-closed:** there is no maintained pure-Python
|
||||||
|
Argon2 implementation; a pure-Python crypto fallback is a liability
|
||||||
|
(weaker hashing, violates INV-16's spirit). If the ``argon2`` C
|
||||||
|
extension fails to import, the Lambda **fails closed** —
|
||||||
|
``_ARGON2_AVAILABLE`` is set ``False`` at cold-start, and
|
||||||
|
:func:`hash_password` / :func:`verify_password` raise
|
||||||
|
``Argon2UnavailableError``. The handler catches this and returns
|
||||||
|
**HTTP 503** (``{"error": "argon2_unavailable"}``) — **no pure-Python
|
||||||
|
fallback, no weak hash, no crash.** This is verified by the explicit
|
||||||
|
``test_argon2_fail_closed`` test (C-1.2).
|
||||||
|
|
||||||
|
## No raw passwords anywhere (INV-16)
|
||||||
|
|
||||||
|
Raw passwords are NEVER:
|
||||||
|
* written to DynamoDB (only ``password_hash`` is stored),
|
||||||
|
* logged (the handler never logs the password argument),
|
||||||
|
* put in traces / env vars / X-Ray segments.
|
||||||
|
|
||||||
|
Audit events (``auth.sign_up``, ``auth.sign_in``,
|
||||||
|
``auth.session_created``) are emitted to stderr as JSON; they carry the
|
||||||
|
``user_id`` / ``email`` but **never** the password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argon2id — fail-closed import (REQ-334, D-228, C-7.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# try-import the C extension. If it fails (missing abi3 wheel, wrong
|
||||||
|
# glibc, etc.), _ARGON2_AVAILABLE becomes False and hash/verify raise
|
||||||
|
# Argon2UnavailableError. The handler returns 503. NO pure-Python fallback.
|
||||||
|
_ARGON2_AVAILABLE = False
|
||||||
|
_PasswordHasher = None
|
||||||
|
|
||||||
|
try: # pragma: no cover - import success path covered by round-trip test
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
_PasswordHasher = PasswordHasher
|
||||||
|
_ARGON2_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover - exercised via mock in tests
|
||||||
|
_ARGON2_AVAILABLE = False
|
||||||
|
|
||||||
|
# Define a stand-in so `verify_password` can raise the right type
|
||||||
|
# even when argon2 isn't importable. VerifyMismatchError is only
|
||||||
|
# raised by verify() which itself raises Argon2UnavailableError first.
|
||||||
|
class VerifyMismatchError(Exception):
|
||||||
|
"""Raised by verify_password when the password does not match."""
|
||||||
|
|
||||||
|
|
||||||
|
class Argon2UnavailableError(Exception):
|
||||||
|
"""Raised when the Argon2 C extension is unavailable (D-228 fail-closed).
|
||||||
|
|
||||||
|
The handler catches this and returns HTTP 503 — no pure-Python
|
||||||
|
fallback, no weak hash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# OWASP-minimum Argon2id parameters (C-7.2):
|
||||||
|
# time_cost=3, memory_cost=65536 KiB (64 MiB), parallelism=1
|
||||||
|
_ARGON2_TIME_COST = 3
|
||||||
|
_ARGON2_MEMORY_COST = 65536 # KiB
|
||||||
|
_ARGON2_PARALLELISM = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _get_hasher():
|
||||||
|
"""Return a PasswordHasher configured with the OWASP-min params.
|
||||||
|
|
||||||
|
Raises Argon2UnavailableError if the C extension is not loaded.
|
||||||
|
"""
|
||||||
|
if not _ARGON2_AVAILABLE or _PasswordHasher is None:
|
||||||
|
raise Argon2UnavailableError(
|
||||||
|
"argon2 C extension unavailable — refusing to hash with a "
|
||||||
|
"weak fallback (D-228 fail-closed)"
|
||||||
|
)
|
||||||
|
return _PasswordHasher(
|
||||||
|
time_cost=_ARGON2_TIME_COST,
|
||||||
|
memory_cost=_ARGON2_MEMORY_COST,
|
||||||
|
parallelism=_ARGON2_PARALLELISM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password with Argon2id (OWASP-min params).
|
||||||
|
|
||||||
|
Returns the Argon2id hash string (includes the salt + params).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||||
|
importable (D-228 fail-closed — NO pure-Python fallback).
|
||||||
|
"""
|
||||||
|
if not _ARGON2_AVAILABLE:
|
||||||
|
raise Argon2UnavailableError(
|
||||||
|
"argon2 C extension unavailable — refusing to hash (D-228)"
|
||||||
|
)
|
||||||
|
# NOTE: the password argument is NEVER logged. Do not add debug
|
||||||
|
# prints here that include `password`.
|
||||||
|
return _get_hasher().hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hash_str: str) -> bool:
|
||||||
|
"""Verify a password against an Argon2id hash.
|
||||||
|
|
||||||
|
Returns ``True`` if the password matches.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||||
|
importable.
|
||||||
|
VerifyMismatchError: if the password does not match the hash.
|
||||||
|
"""
|
||||||
|
if not _ARGON2_AVAILABLE:
|
||||||
|
raise Argon2UnavailableError(
|
||||||
|
"argon2 C extension unavailable — refusing to verify (D-228)"
|
||||||
|
)
|
||||||
|
# argon2.PasswordHasher().verify raises VerifyMismatchError on
|
||||||
|
# mismatch (and InvalidHash on a malformed hash). We let those
|
||||||
|
# propagate; the handler maps them to 401 / 500.
|
||||||
|
_get_hasher().verify(hash_str, password)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config (env-var table names, mirroring contract_ingestor.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
USERS_TABLE = os.environ.get("NOVA_USERS_TABLE", "nova-users")
|
||||||
|
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||||
|
PASSWORD_RESETS_TABLE = os.environ.get(
|
||||||
|
"NOVA_PASSWORD_RESETS_TABLE", "nova-password-resets"
|
||||||
|
)
|
||||||
|
# Session lifetime (seconds). Default 24h.
|
||||||
|
SESSION_TTL_SECONDS = int(os.environ.get("NOVA_SESSION_TTL_SECONDS", "86400"))
|
||||||
|
# Password-reset token lifetime (seconds). Default 15 min.
|
||||||
|
RESET_TTL_SECONDS = int(os.environ.get("NOVA_RESET_TTL_SECONDS", "900"))
|
||||||
|
|
||||||
|
_dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamodb():
|
||||||
|
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||||
|
global _dynamodb
|
||||||
|
if _dynamodb is None:
|
||||||
|
_dynamodb = boto3.resource("dynamodb")
|
||||||
|
return _dynamodb
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_now() -> int:
|
||||||
|
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
"""Emit an audit event to stderr as JSON (never includes passwords)."""
|
||||||
|
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||||
|
# Defense-in-depth: scrub any field literally named 'password' or
|
||||||
|
# 'password_hash' value from the audit payload (they should never be
|
||||||
|
# passed here, but a stray kwarg would leak — INV-16).
|
||||||
|
for _k in ("password", "new_password", "old_password"):
|
||||||
|
payload.pop(_k, None)
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Business logic (sign_up / sign_in / create_session / reset flows)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require(fields, payload):
|
||||||
|
"""Validate required fields; raise ValueError (→ 400) if missing."""
|
||||||
|
for f in fields:
|
||||||
|
if f not in payload or payload[f] in (None, ""):
|
||||||
|
raise ValueError(f"missing field: {f}")
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_user_by_email(email: str):
|
||||||
|
"""Query nova-users GSI1 (email-index) → return the user item or None."""
|
||||||
|
table = _get_dynamodb().Table(USERS_TABLE)
|
||||||
|
resp = table.query(
|
||||||
|
IndexName="email-index",
|
||||||
|
KeyConditionExpression="email = :e",
|
||||||
|
ExpressionAttributeValues={":e": email},
|
||||||
|
Limit=1,
|
||||||
|
)
|
||||||
|
items = resp.get("Items", [])
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
def sign_up(payload):
|
||||||
|
"""Create a new user. Fails closed (503) if argon2 is unavailable.
|
||||||
|
|
||||||
|
Payload: { email, password, owner, roles }
|
||||||
|
Writes to nova-users: PK user_id (uuid4), email, password_hash,
|
||||||
|
owner, roles, created_at. The raw password is NEVER stored.
|
||||||
|
"""
|
||||||
|
_require(("email", "password", "owner", "roles"), payload)
|
||||||
|
if not _ARGON2_AVAILABLE:
|
||||||
|
raise Argon2UnavailableError("argon2 unavailable")
|
||||||
|
email = payload["email"]
|
||||||
|
password = payload["password"]
|
||||||
|
owner = payload["owner"]
|
||||||
|
roles = payload["roles"]
|
||||||
|
if not isinstance(roles, list):
|
||||||
|
raise ValueError("roles must be a list")
|
||||||
|
|
||||||
|
# Duplicate-email check → 409.
|
||||||
|
if _lookup_user_by_email(email) is not None:
|
||||||
|
raise _DuplicateEmailError(email)
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
password_hash = hash_password(password) # fail-closed here
|
||||||
|
created_at = _iso8601_now()
|
||||||
|
item = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"password_hash": password_hash,
|
||||||
|
"owner": owner,
|
||||||
|
"roles": roles,
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
table = _get_dynamodb().Table(USERS_TABLE)
|
||||||
|
table.put_item(TableName=USERS_TABLE, Item=item)
|
||||||
|
_emit_audit("auth.sign_up", user_id=user_id, email=email)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": "sign_up",
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _DuplicateEmailError(Exception):
|
||||||
|
"""Raised when sign_up is called with an already-registered email → 409."""
|
||||||
|
|
||||||
|
def __init__(self, email: str):
|
||||||
|
self.email = email
|
||||||
|
super().__init__(f"email already registered: {email}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(user_id: str) -> str:
|
||||||
|
"""Create a session row in nova-sessions; return the session_id.
|
||||||
|
|
||||||
|
TTL: expires_at = now + SESSION_TTL_SECONDS (epoch seconds).
|
||||||
|
"""
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
now = _epoch_now()
|
||||||
|
expires_at = now + SESSION_TTL_SECONDS
|
||||||
|
created_at = _iso8601_now()
|
||||||
|
table = _get_dynamodb().Table(SESSIONS_TABLE)
|
||||||
|
table.put_item(
|
||||||
|
TableName=SESSIONS_TABLE,
|
||||||
|
Item={
|
||||||
|
"session_id": session_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"created_at": created_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_audit("auth.session_created", user_id=user_id, session_id=session_id)
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(payload):
|
||||||
|
"""Sign in by email + password → return a session_id.
|
||||||
|
|
||||||
|
On wrong password → raises VerifyMismatchError (→ 401).
|
||||||
|
On unknown email → raises _UnknownUserError (→ 401, same code to
|
||||||
|
avoid user-enumeration via timing — the message is generic).
|
||||||
|
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||||
|
"""
|
||||||
|
_require(("email", "password"), payload)
|
||||||
|
if not _ARGON2_AVAILABLE:
|
||||||
|
raise Argon2UnavailableError("argon2 unavailable")
|
||||||
|
email = payload["email"]
|
||||||
|
password = payload["password"]
|
||||||
|
user = _lookup_user_by_email(email)
|
||||||
|
if user is None:
|
||||||
|
# Generic 401 — do not reveal whether the email is registered
|
||||||
|
# (user-enumeration defense).
|
||||||
|
raise _UnknownUserError("invalid credentials")
|
||||||
|
try:
|
||||||
|
verify_password(password, user["password_hash"])
|
||||||
|
except VerifyMismatchError:
|
||||||
|
raise _UnknownUserError("invalid credentials")
|
||||||
|
session_id = create_session(user["user_id"])
|
||||||
|
_emit_audit("auth.sign_in", user_id=user["user_id"], email=email)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": "sign_in",
|
||||||
|
"user_id": user["user_id"],
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _UnknownUserError(Exception):
|
||||||
|
"""Generic 'invalid credentials' — 401 (no user enumeration)."""
|
||||||
|
|
||||||
|
|
||||||
|
def request_password_reset(payload):
|
||||||
|
"""Generate a reset token (uuid4) → write to nova-password-resets (15 min TTL).
|
||||||
|
|
||||||
|
Returns the token directly (in a real system this would be emailed;
|
||||||
|
for v1.28 it is returned so tests / the CLI can drive reset_password).
|
||||||
|
"""
|
||||||
|
_require(("email",), payload)
|
||||||
|
email = payload["email"]
|
||||||
|
user = _lookup_user_by_email(email)
|
||||||
|
if user is None:
|
||||||
|
# Return ok regardless (no user enumeration via reset endpoint).
|
||||||
|
# We still return a (fake) token shape so the response is uniform;
|
||||||
|
# the token is single-use and reset_password validates against DDB.
|
||||||
|
_emit_audit("auth.password_reset_requested", email=email, found=False)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": "request_password_reset",
|
||||||
|
"reset_token": None,
|
||||||
|
"message": "if the email is registered, a reset token was issued",
|
||||||
|
}
|
||||||
|
reset_token = str(uuid.uuid4())
|
||||||
|
now = _epoch_now()
|
||||||
|
expires_at = now + RESET_TTL_SECONDS
|
||||||
|
table = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||||
|
table.put_item(
|
||||||
|
TableName=PASSWORD_RESETS_TABLE,
|
||||||
|
Item={
|
||||||
|
"reset_token": reset_token,
|
||||||
|
"user_id": user["user_id"],
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"created_at": _iso8601_now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_audit(
|
||||||
|
"auth.password_reset_requested",
|
||||||
|
user_id=user["user_id"],
|
||||||
|
email=email,
|
||||||
|
found=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": "request_password_reset",
|
||||||
|
"reset_token": reset_token,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(payload):
|
||||||
|
"""Validate a reset token → set a new password → delete the token.
|
||||||
|
|
||||||
|
Payload: { reset_token, new_password }
|
||||||
|
On invalid/expired token → ValueError (→ 400).
|
||||||
|
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||||
|
"""
|
||||||
|
_require(("reset_token", "new_password"), payload)
|
||||||
|
if not _ARGON2_AVAILABLE:
|
||||||
|
raise Argon2UnavailableError("argon2 unavailable")
|
||||||
|
reset_token = payload["reset_token"]
|
||||||
|
new_password = payload["new_password"]
|
||||||
|
resets = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||||
|
resp = resets.get_item(
|
||||||
|
TableName=PASSWORD_RESETS_TABLE,
|
||||||
|
Key={"reset_token": reset_token},
|
||||||
|
)
|
||||||
|
item = resp.get("Item")
|
||||||
|
if not item:
|
||||||
|
raise ValueError("invalid or expired reset token")
|
||||||
|
if item.get("expires_at", 0) < _epoch_now():
|
||||||
|
# Token expired (TTL may not have reaped it yet).
|
||||||
|
raise ValueError("reset token expired")
|
||||||
|
user_id = item["user_id"]
|
||||||
|
new_hash = hash_password(new_password) # fail-closed
|
||||||
|
users = _get_dynamodb().Table(USERS_TABLE)
|
||||||
|
users.update_item(
|
||||||
|
TableName=USERS_TABLE,
|
||||||
|
Key={"user_id": user_id},
|
||||||
|
UpdateExpression="SET password_hash = :h",
|
||||||
|
ExpressionAttributeValues={":h": new_hash},
|
||||||
|
)
|
||||||
|
resets.delete_item(
|
||||||
|
TableName=PASSWORD_RESETS_TABLE,
|
||||||
|
Key={"reset_token": reset_token},
|
||||||
|
)
|
||||||
|
_emit_audit("auth.password_reset", user_id=user_id)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": "reset_password",
|
||||||
|
"user_id": user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch (shared by Lambda handler + CLI — REQ-329 dual-use)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_action(payload, event=None):
|
||||||
|
"""Shared business-logic dispatch for the IdP auth Lambda (REQ-329).
|
||||||
|
|
||||||
|
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||||
|
(``cli_main`` / ``__main__``) call this so the two paths share a
|
||||||
|
single source of truth for action routing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: the decoded action envelope dict, e.g.
|
||||||
|
``{ action: "sign_up", email, password, owner, roles }``.
|
||||||
|
event: the raw Lambda Function-URL event (unused for identity —
|
||||||
|
the IAM auth is enforced at the Function URL layer; kept for
|
||||||
|
signature symmetry with contract_ingestor).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The action result dict on success. Raises on error — the caller
|
||||||
|
maps exceptions to status codes via :func:`_to_http_response`.
|
||||||
|
"""
|
||||||
|
action = payload.get("action")
|
||||||
|
if action == "sign_up":
|
||||||
|
return sign_up(payload)
|
||||||
|
if action == "sign_in":
|
||||||
|
return sign_in(payload)
|
||||||
|
if action == "create_session":
|
||||||
|
_require(("user_id",), payload)
|
||||||
|
sid = create_session(payload["user_id"])
|
||||||
|
return {"status": "ok", "action": "create_session", "session_id": sid}
|
||||||
|
if action == "request_password_reset":
|
||||||
|
return request_password_reset(payload)
|
||||||
|
if action == "reset_password":
|
||||||
|
return reset_password(payload)
|
||||||
|
raise ValueError(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_http_response(result_or_error):
|
||||||
|
"""Map a dispatch result / exception to a Lambda HTTP response."""
|
||||||
|
if isinstance(result_or_error, Exception):
|
||||||
|
# Fail-closed: argon2 unavailable → 503 (NO weak hash, NO crash).
|
||||||
|
if isinstance(result_or_error, Argon2UnavailableError):
|
||||||
|
return {
|
||||||
|
"statusCode": 503,
|
||||||
|
"body": json.dumps({"error": "argon2_unavailable"}),
|
||||||
|
}
|
||||||
|
if isinstance(result_or_error, _DuplicateEmailError):
|
||||||
|
return {
|
||||||
|
"statusCode": 409,
|
||||||
|
"body": json.dumps({"error": "email_already_registered"}),
|
||||||
|
}
|
||||||
|
if isinstance(result_or_error, _UnknownUserError):
|
||||||
|
return {
|
||||||
|
"statusCode": 401,
|
||||||
|
"body": json.dumps({"error": "invalid_credentials"}),
|
||||||
|
}
|
||||||
|
if isinstance(result_or_error, ValueError):
|
||||||
|
return {
|
||||||
|
"statusCode": 400,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||||
|
|
||||||
|
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||||
|
containing ``{ action, email, password, ... }``. Parses the envelope
|
||||||
|
then delegates to :func:`dispatch_action`.
|
||||||
|
"""
|
||||||
|
# Fail-closed fast-path: if argon2 is unavailable, sign_up / sign_in /
|
||||||
|
# reset_password all raise Argon2UnavailableError which maps to 503.
|
||||||
|
# We do NOT short-circuit here so non-password actions (create_session)
|
||||||
|
# still work when argon2 is down — only the hashing paths fail closed.
|
||||||
|
try:
|
||||||
|
body = event.get("body", "{}")
|
||||||
|
payload = json.loads(body) if isinstance(body, str) else body
|
||||||
|
result = dispatch_action(payload, event=event)
|
||||||
|
return _to_http_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _to_http_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI (dual-use, REQ-329 pattern)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main(argv=None):
|
||||||
|
"""CLI entry point for the IdP auth Lambda (REQ-329 dual-use).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m core.lambda.nova_idp_auth --sign-up <email> <password> <owner>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --sign-in <email> <password>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --create-session <user_id>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --request-reset <email>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --reset-password <token> <new_password>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --dispatch <payload.json>
|
||||||
|
python3 -m core.lambda.nova_idp_auth --dispatch-stdin < <payload.json>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||||
|
try:
|
||||||
|
if "--dispatch-stdin" in raw:
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
elif "--dispatch" in raw:
|
||||||
|
idx = raw.index("--dispatch")
|
||||||
|
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||||
|
if not path:
|
||||||
|
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(path) as fh:
|
||||||
|
payload = json.loads(fh.read())
|
||||||
|
elif "--sign-up" in raw:
|
||||||
|
idx = raw.index("--sign-up")
|
||||||
|
email, password, owner = raw[idx + 1 : idx + 4]
|
||||||
|
roles = ["user"]
|
||||||
|
payload = {
|
||||||
|
"action": "sign_up",
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"owner": owner,
|
||||||
|
"roles": roles,
|
||||||
|
}
|
||||||
|
elif "--sign-in" in raw:
|
||||||
|
idx = raw.index("--sign-in")
|
||||||
|
email, password = raw[idx + 1 : idx + 3]
|
||||||
|
payload = {"action": "sign_in", "email": email, "password": password}
|
||||||
|
elif "--create-session" in raw:
|
||||||
|
idx = raw.index("--create-session")
|
||||||
|
user_id = raw[idx + 1]
|
||||||
|
payload = {"action": "create_session", "user_id": user_id}
|
||||||
|
elif "--request-reset" in raw:
|
||||||
|
idx = raw.index("--request-reset")
|
||||||
|
email = raw[idx + 1]
|
||||||
|
payload = {"action": "request_password_reset", "email": email}
|
||||||
|
elif "--reset-password" in raw:
|
||||||
|
idx = raw.index("--reset-password")
|
||||||
|
token, new_password = raw[idx + 1 : idx + 3]
|
||||||
|
payload = {
|
||||||
|
"action": "reset_password",
|
||||||
|
"reset_token": token,
|
||||||
|
"new_password": new_password,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Usage: python3 -m core.lambda.nova_idp_auth "
|
||||||
|
"--sign-up <email> <password> <owner> | "
|
||||||
|
"--sign-in <email> <password> | "
|
||||||
|
"--dispatch <payload.json>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
result = dispatch_action(payload, event=None)
|
||||||
|
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
except Argon2UnavailableError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 3 # 503-class
|
||||||
|
except ValueError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 1
|
||||||
|
except _DuplicateEmailError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 9 # 409-class
|
||||||
|
except _UnknownUserError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 1 # 401-class
|
||||||
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
|
sys.stderr.write(f"internal error: {e}\n")
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.exit(cli_main())
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""CloudFormation snippet for the Nova IdP DynamoDB identity schema (REQ-335).
|
||||||
|
|
||||||
|
This module exports :func:`dynamodb_tables_snippet`, which returns a
|
||||||
|
CloudFormation fragment (a plain ``dict``) defining the four DynamoDB
|
||||||
|
tables that back the Nova identity provider:
|
||||||
|
|
||||||
|
* ``nova-users`` — user records (PK ``user_id``, GSI1 ``email``)
|
||||||
|
* ``nova-sessions`` — session tokens (PK ``session_id``, GSI1
|
||||||
|
``user_id``, TTL ``expires_at``)
|
||||||
|
* ``nova-password-resets`` — reset tokens (PK ``reset_token``, TTL
|
||||||
|
``expires_at`` — 15 min)
|
||||||
|
* ``nova-pats`` — personal access tokens (PK ``jti``, GSI1
|
||||||
|
``sub``, GSI2 ``pat_hash``). This table is consumed in P4 (OIDC/PAT
|
||||||
|
issuance) but is defined here so a single ``nova idp setup``
|
||||||
|
CloudFormation template provisions the complete identity backend.
|
||||||
|
|
||||||
|
Design notes (REQ-335):
|
||||||
|
* All tables use ``BillingMode: PAY_PER_REQUEST`` (on-demand) — the
|
||||||
|
IdP traffic is bursty and unpredictable; provisioned capacity would
|
||||||
|
either throttle or waste money.
|
||||||
|
* PITR (``PointInTimeRecoverySpecification``) is enabled on
|
||||||
|
``nova-users`` — user records are irreplaceable; continuous backup
|
||||||
|
protects against accidental deletes / corrupt writes. The session /
|
||||||
|
reset / PAT tables are ephemeral (TTL-managed) so PITR is not
|
||||||
|
required there, but enabling it is cheap insurance; we enable it on
|
||||||
|
``nova-users`` per REQ-335 and leave the others as on-demand only
|
||||||
|
(TTL is the recovery mechanism for those).
|
||||||
|
* TTL attributes (``expires_at``) are epoch seconds — DynamoDB TTL
|
||||||
|
silently deletes expired items in the background (best-effort, do
|
||||||
|
not rely on for access control; the handler also checks ``expires_at``
|
||||||
|
on read).
|
||||||
|
|
||||||
|
The fragment is composed into the full ``nova idp setup`` template in
|
||||||
|
P4 Wave 8 (``nova idp setup --apply``). The keys in the returned dict
|
||||||
|
are CloudFormation logical resource IDs (``NovaUsersTable``, etc.) so
|
||||||
|
the composer can merge it directly into a template's ``Resources``
|
||||||
|
section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _attribute(name: str, attr_type: str = "S") -> Dict[str, str]:
|
||||||
|
return {"AttributeName": name, "AttributeType": attr_type}
|
||||||
|
|
||||||
|
|
||||||
|
def _key_schema(name: str, key_type: str = "HASH") -> Dict[str, str]:
|
||||||
|
return {"AttributeName": name, "KeyType": key_type}
|
||||||
|
|
||||||
|
|
||||||
|
def dynamodb_tables_snippet() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Return a CloudFormation fragment defining the four IdP DynamoDB tables.
|
||||||
|
|
||||||
|
The returned dict maps logical resource IDs to CloudFormation
|
||||||
|
resource dicts (``Type: AWS::DynamoDB::Table``). It is intended to be
|
||||||
|
merged into the ``Resources`` block of the full
|
||||||
|
``nova idp setup`` template (P4 Wave 8).
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
* ``NovaUsersTable`` (``nova-users``)
|
||||||
|
* ``NovaSessionsTable`` (``nova-sessions``)
|
||||||
|
* ``NovaPasswordResetsTable`` (``nova-password-resets``)
|
||||||
|
* ``NovaPatsTable`` (``nova-pats``)
|
||||||
|
|
||||||
|
All tables are ``PAY_PER_REQUEST`` (on-demand). PITR is enabled on
|
||||||
|
``nova-users`` (REQ-335). TTL is enabled on the three ephemeral
|
||||||
|
tables (``expires_at`` epoch-seconds attribute).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# nova-users — the user directory (PK user_id, GSI1 email).
|
||||||
|
# PITR enabled: user records are irreplaceable.
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"NovaUsersTable": {
|
||||||
|
"Type": "AWS::DynamoDB::Table",
|
||||||
|
"Properties": {
|
||||||
|
"TableName": "nova-users",
|
||||||
|
"BillingMode": "PAY_PER_REQUEST",
|
||||||
|
"KeySchema": [
|
||||||
|
_key_schema("user_id", "HASH"),
|
||||||
|
],
|
||||||
|
"AttributeDefinitions": [
|
||||||
|
_attribute("user_id", "S"),
|
||||||
|
_attribute("email", "S"),
|
||||||
|
],
|
||||||
|
"GlobalSecondaryIndexes": [
|
||||||
|
{
|
||||||
|
"IndexName": "email-index",
|
||||||
|
"KeySchema": [_key_schema("email", "HASH")],
|
||||||
|
"Projection": {"ProjectionType": "ALL"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"PointInTimeRecoverySpecification": {
|
||||||
|
"PointInTimeRecoveryEnabled": True,
|
||||||
|
},
|
||||||
|
# Attribute shape (for documentation / the setup --dry-run
|
||||||
|
# summary; DynamoDB is schemaless so this is not enforced):
|
||||||
|
# user_id String (PK)
|
||||||
|
# email String (GSI1 hash, unique)
|
||||||
|
# password_hash String (Argon2id, never the raw password)
|
||||||
|
# owner String
|
||||||
|
# roles List
|
||||||
|
# created_at String (ISO-8601)
|
||||||
|
"AttributeShape": {
|
||||||
|
"user_id": "String",
|
||||||
|
"email": "String",
|
||||||
|
"password_hash": "String",
|
||||||
|
"owner": "String",
|
||||||
|
"roles": "List",
|
||||||
|
"created_at": "String",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# nova-sessions — session tokens (PK session_id, GSI1 user_id).
|
||||||
|
# TTL: expires_at (epoch seconds). Sessions live 24h.
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"NovaSessionsTable": {
|
||||||
|
"Type": "AWS::DynamoDB::Table",
|
||||||
|
"Properties": {
|
||||||
|
"TableName": "nova-sessions",
|
||||||
|
"BillingMode": "PAY_PER_REQUEST",
|
||||||
|
"KeySchema": [
|
||||||
|
_key_schema("session_id", "HASH"),
|
||||||
|
],
|
||||||
|
"AttributeDefinitions": [
|
||||||
|
_attribute("session_id", "S"),
|
||||||
|
_attribute("user_id", "S"),
|
||||||
|
],
|
||||||
|
"GlobalSecondaryIndexes": [
|
||||||
|
{
|
||||||
|
"IndexName": "user_id-index",
|
||||||
|
"KeySchema": [_key_schema("user_id", "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)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# nova-password-resets — reset tokens (PK reset_token).
|
||||||
|
# TTL: expires_at (epoch seconds). Tokens live 15 min.
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"NovaPasswordResetsTable": {
|
||||||
|
"Type": "AWS::DynamoDB::Table",
|
||||||
|
"Properties": {
|
||||||
|
"TableName": "nova-password-resets",
|
||||||
|
"BillingMode": "PAY_PER_REQUEST",
|
||||||
|
"KeySchema": [
|
||||||
|
_key_schema("reset_token", "HASH"),
|
||||||
|
],
|
||||||
|
"AttributeDefinitions": [
|
||||||
|
_attribute("reset_token", "S"),
|
||||||
|
],
|
||||||
|
"TimeToLiveSpecification": {
|
||||||
|
"AttributeName": "expires_at",
|
||||||
|
"Enabled": True,
|
||||||
|
},
|
||||||
|
"AttributeShape": {
|
||||||
|
"reset_token": "String",
|
||||||
|
"user_id": "String",
|
||||||
|
"expires_at": "String (epoch seconds, TTL; 15 min)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# nova-pats — personal access tokens (PK jti, GSI1 sub, GSI2 pat_hash).
|
||||||
|
# Consumed in P4 (OIDC/PAT issuance) but defined here so the single
|
||||||
|
# CloudFormation template provisions the complete identity backend.
|
||||||
|
# TTL: expires_at (epoch seconds).
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"NovaPatsTable": {
|
||||||
|
"Type": "AWS::DynamoDB::Table",
|
||||||
|
"Properties": {
|
||||||
|
"TableName": "nova-pats",
|
||||||
|
"BillingMode": "PAY_PER_REQUEST",
|
||||||
|
"KeySchema": [
|
||||||
|
_key_schema("jti", "HASH"),
|
||||||
|
],
|
||||||
|
"AttributeDefinitions": [
|
||||||
|
_attribute("jti", "S"),
|
||||||
|
_attribute("sub", "S"),
|
||||||
|
_attribute("pat_hash", "S"),
|
||||||
|
],
|
||||||
|
"GlobalSecondaryIndexes": [
|
||||||
|
{
|
||||||
|
"IndexName": "sub-index",
|
||||||
|
"KeySchema": [_key_schema("sub", "HASH")],
|
||||||
|
"Projection": {"ProjectionType": "ALL"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"IndexName": "pat_hash-index",
|
||||||
|
"KeySchema": [_key_schema("pat_hash", "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)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def table_names() -> Dict[str, str]:
|
||||||
|
"""Return the logical→physical table-name mapping (for env-var defaults)."""
|
||||||
|
return {
|
||||||
|
"users": "nova-users",
|
||||||
|
"sessions": "nova-sessions",
|
||||||
|
"password_resets": "nova-password-resets",
|
||||||
|
"pats": "nova-pats",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if "--names" in sys.argv:
|
||||||
|
sys.stdout.write(json.dumps(table_names(), indent=2) + "\n")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(json.dumps(dynamodb_tables_snippet(), indent=2) + "\n")
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""CloudFormation template for the Nova IdP (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Composes the DynamoDB snippet (from P3 ``nova_idp_auth_cfn.py``) + 3
|
||||||
|
Lambdas (``nova-idp-auth``, ``nova-idp-token-vend``, ``nova-idp-jwks``)
|
||||||
|
+ KMS key (``alias/nova-oidc-signing``, ``ECC_NIST_P256``,
|
||||||
|
``SIGN_VERIFY``) + function URLs + IAM roles + optional
|
||||||
|
CloudFront/WAF/ACM (when ``public_jwks_domain`` is provided).
|
||||||
|
|
||||||
|
:func:`generate_template` returns a CloudFormation template dict (no
|
||||||
|
troposphere dependency — raw dict → JSON).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_auth_cfn():
|
||||||
|
"""Load core/lambda/nova_idp_auth_cfn.py via importlib (`lambda` is reserved)."""
|
||||||
|
p = Path(__file__).parent / "nova_idp_auth_cfn.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_auth_cfn", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_auth_cfn = _load_auth_cfn()
|
||||||
|
dynamodb_tables_snippet = _auth_cfn.dynamodb_tables_snippet
|
||||||
|
table_names = _auth_cfn.table_names
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_role(logical_id: str, table_envs: dict[str, str], kms: bool = False) -> dict:
|
||||||
|
"""Build an IAM role for a Nova IdP Lambda."""
|
||||||
|
statements = [
|
||||||
|
{
|
||||||
|
"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}:*"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if table_envs:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
|
||||||
|
"dynamodb:Query", "dynamodb:DeleteItem"],
|
||||||
|
"Resource": [
|
||||||
|
{"Fn::Sub": f"arn:aws:dynamodb:${{AWS::Region}}:${{AWS::AccountId}}:table/{name}"}
|
||||||
|
for name in table_envs.values()
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if kms:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
||||||
|
"Resource": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"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": f"{logical_id}Policy", "PolicyDocument": {
|
||||||
|
"Version": "2012-10-17", "Statement": statements,
|
||||||
|
}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_function(logical_id: str, handler: str, role_ref: str,
|
||||||
|
env_vars: dict[str, str], memory: int = 512) -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Function",
|
||||||
|
"Properties": {
|
||||||
|
"Handler": handler,
|
||||||
|
"Runtime": "python3.12",
|
||||||
|
"MemorySize": memory,
|
||||||
|
"Timeout": 30,
|
||||||
|
"Role": {"Fn::GetAtt": [role_ref, "Arn"]},
|
||||||
|
"Environment": {"Variables": env_vars},
|
||||||
|
"Code": {"ZipFile": "def lambda_handler(event, context):\n return {}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _function_url(logical_id: str, auth_type: str = "AWS_IAM") -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Url",
|
||||||
|
"Properties": {
|
||||||
|
"TargetFunction": {"Ref": logical_id},
|
||||||
|
"AuthType": auth_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template(public_jwks_domain: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Generate the full Nova IdP CloudFormation template (REQ-340).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_jwks_domain: optional custom domain for the JWKS endpoint.
|
||||||
|
When provided, CloudFront + ACM + WAF resources are added.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A CloudFormation template dict (``{"Resources": {...}}``).
|
||||||
|
"""
|
||||||
|
resources: Dict[str, Any] = {}
|
||||||
|
# DynamoDB tables (from P3).
|
||||||
|
resources.update(dynamodb_tables_snippet())
|
||||||
|
names = table_names()
|
||||||
|
|
||||||
|
# KMS key (ECC_NIST_P256, SIGN_VERIFY) + alias.
|
||||||
|
resources["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": "*",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaOidcSigningKeyAlias"] = {
|
||||||
|
"Type": "AWS::KMS::Alias",
|
||||||
|
"Properties": {
|
||||||
|
"AliasName": "alias/nova-oidc-signing",
|
||||||
|
"TargetKeyId": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lambda roles.
|
||||||
|
auth_tables = {"users": names["users"], "sessions": names["sessions"],
|
||||||
|
"password_resets": names["password_resets"]}
|
||||||
|
resources["NovaIdpAuthRole"] = _lambda_role("NovaIdpAuth", auth_tables)
|
||||||
|
resources["NovaIdpTokenVendRole"] = _lambda_role(
|
||||||
|
"NovaIdpTokenVend", {"pats": names["pats"]}, kms=True)
|
||||||
|
resources["NovaIdpJwksRole"] = _lambda_role("NovaIdpJwks", {}, kms=True)
|
||||||
|
|
||||||
|
# Lambda functions.
|
||||||
|
common_env = {
|
||||||
|
"NOVA_USERS_TABLE": names["users"],
|
||||||
|
"NOVA_SESSIONS_TABLE": names["sessions"],
|
||||||
|
"NOVA_PASSWORD_RESETS_TABLE": names["password_resets"],
|
||||||
|
"NOVA_PATS_TABLE": names["pats"],
|
||||||
|
}
|
||||||
|
resources["NovaIdpAuthFunction"] = _lambda_function(
|
||||||
|
"NovaIdpAuth", "nova_idp_auth.lambda_handler", "NovaIdpAuthRole", common_env)
|
||||||
|
resources["NovaIdpTokenVendFunction"] = _lambda_function(
|
||||||
|
"NovaIdpTokenVend", "nova_idp_token_vend.lambda_handler", "NovaIdpTokenVendRole",
|
||||||
|
{**common_env, "NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"})
|
||||||
|
resources["NovaIdpJwksFunction"] = _lambda_function(
|
||||||
|
"NovaIdpJwks", "nova_idp_jwks.lambda_handler", "NovaIdpJwksRole",
|
||||||
|
{"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"}, memory=256)
|
||||||
|
|
||||||
|
# Function URLs (auth Lambda: IAM; token-vend: IAM; jwks: NONE — public).
|
||||||
|
resources["NovaIdpAuthUrl"] = _function_url("NovaIdpAuthFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpTokenVendUrl"] = _function_url("NovaIdpTokenVendFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpJwksUrl"] = _function_url("NovaIdpJwksFunction", "NONE")
|
||||||
|
|
||||||
|
# Optional: CloudFront + ACM + WAF for a custom JWKS domain.
|
||||||
|
if public_jwks_domain:
|
||||||
|
resources["NovaJwksCloudFront"] = {
|
||||||
|
"Type": "AWS::CloudFront::Distribution",
|
||||||
|
"Properties": {
|
||||||
|
"DistributionConfig": {
|
||||||
|
"Enabled": True,
|
||||||
|
"Aliases": [public_jwks_domain],
|
||||||
|
"Origins": [{
|
||||||
|
"DomainName": {"Fn::GetAtt": "NovaIdpJwksUrl.Endpoint"},
|
||||||
|
"Id": "JwksOrigin",
|
||||||
|
"CustomOriginConfig": {"OriginProtocolPolicy": "https-only"},
|
||||||
|
}],
|
||||||
|
"DefaultCacheBehavior": {
|
||||||
|
"TargetOriginId": "JwksOrigin",
|
||||||
|
"ViewerProtocolPolicy": "redirect-to-https",
|
||||||
|
"ForwardedValues": {"QueryString": False},
|
||||||
|
},
|
||||||
|
"ViewerCertificate": {
|
||||||
|
"AcmCertificateArn": {"Ref": "NovaJwksAcmCert"},
|
||||||
|
"SslSupportMethod": "sni-only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaJwksAcmCert"] = {
|
||||||
|
"Type": "AWS::CertificateManager::Certificate",
|
||||||
|
"Properties": {"DomainName": public_jwks_domain,
|
||||||
|
"ValidationMethod": "DNS"},
|
||||||
|
}
|
||||||
|
resources["NovaJwksWafRateRule"] = {
|
||||||
|
"Type": "AWS::WAFv2::RateBasedRule",
|
||||||
|
"Properties": {
|
||||||
|
"Name": "nova-jwks-rate-limit",
|
||||||
|
"Scope": "CLOUDFRONT",
|
||||||
|
"RateLimit": 100,
|
||||||
|
"Action": {"Block": {}},
|
||||||
|
"ComparisonOperator": "GreaterThan",
|
||||||
|
"AggregateKeyType": "IP",
|
||||||
|
"DefaultCaptchaConfig": {"ImmunityTimeProperty": {"ImmunityTime": 60}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"Resources": resources}
|
||||||
|
|
||||||
|
|
||||||
|
def resource_summary(template: dict) -> dict[str, int]:
|
||||||
|
"""Return ``{resource_type: count}`` for a template (for --dry-run)."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for res in template.get("Resources", {}).values():
|
||||||
|
t = res.get("Type", "Unknown")
|
||||||
|
counts[t] = counts.get(t, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import json, sys
|
||||||
|
domain = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
print(json.dumps(generate_template(domain), indent=2))
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Nova IdP JWKS endpoint Lambda (REQ-338, D-230).
|
||||||
|
|
||||||
|
Serves the KMS public key as a JWK in a standard JWKS response. The
|
||||||
|
endpoint is a Lambda function URL with ``AuthType: NONE`` (JWKS is
|
||||||
|
public-key only — configured in CloudFormation, not in code).
|
||||||
|
|
||||||
|
Response:
|
||||||
|
* ``Content-Type: application/json``
|
||||||
|
* ``Cache-Control: public, max-age=3600`` (1h — clients cache the JWKS)
|
||||||
|
* ``Access-Control-Allow-Origin: *`` (JWKS is public)
|
||||||
|
* ``body: {"keys": [<jwk>]}``
|
||||||
|
|
||||||
|
The JWK is built via :func:`core.kms_signing.get_jwk` from the KMS
|
||||||
|
public key (DER SPKI → ``cryptography`` → JWK).
|
||||||
|
|
||||||
|
Dual-use (REQ-329): ``__main__`` CLI block for local testing
|
||||||
|
(``--print-jwks``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
"""AWS Lambda handler — serve the JWKS response (REQ-338)."""
|
||||||
|
try:
|
||||||
|
from core.kms_signing import get_jwk
|
||||||
|
jwk = get_jwk(key_id=OIDC_KMS_KEY_ID)
|
||||||
|
return {
|
||||||
|
"statusCode": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
"body": json.dumps({"keys": [jwk]}),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"error": str(e)}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main(argv=None):
|
||||||
|
"""CLI entry point (REQ-329 dual-use). ``--print-jwks`` → stdout."""
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
if "--print-jwks" in raw:
|
||||||
|
resp = lambda_handler({}, None)
|
||||||
|
sys.stdout.write(resp["body"] + "\n")
|
||||||
|
return resp.get("statusCode", 200) - 200
|
||||||
|
print("Usage: python3 -m core.lambda.nova_idp_jwks --print-jwks", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
|
sys.exit(cli_main())
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Nova IdP setup logic — check / apply / verify (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``)
|
||||||
|
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
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
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():
|
||||||
|
"""Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved)."""
|
||||||
|
p = Path(__file__).parent / "nova_idp_cfn.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_cfn", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_cfn = _load_cfn()
|
||||||
|
generate_template = _cfn.generate_template
|
||||||
|
resource_summary = _cfn.resource_summary
|
||||||
|
|
||||||
|
|
||||||
|
def check_prerequisites() -> dict[str, Any]:
|
||||||
|
"""Check IdP setup prerequisites (AWS creds, CFN/IAM/KMS perms).
|
||||||
|
|
||||||
|
Returns a report dict:
|
||||||
|
``{"aws_creds": bool, "region": str|None, "missing": [str], "iam_delta": [str]}``
|
||||||
|
"""
|
||||||
|
report: dict[str, Any] = {"aws_creds": False, "region": None, "missing": [], "iam_delta": []}
|
||||||
|
# AWS creds check.
|
||||||
|
try:
|
||||||
|
who = subprocess.check_output(
|
||||||
|
["aws", "sts", "get-caller-identity"], stderr=subprocess.DEVNULL, text=True, timeout=10
|
||||||
|
)
|
||||||
|
report["aws_creds"] = bool(json.loads(who).get("Account"))
|
||||||
|
except Exception:
|
||||||
|
report["missing"].append("aws_credentials (run `aws configure`)")
|
||||||
|
# Region.
|
||||||
|
region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION")
|
||||||
|
report["region"] = region
|
||||||
|
if not region:
|
||||||
|
report["missing"].append("aws_region (set AWS_DEFAULT_REGION)")
|
||||||
|
# IAM policy delta (the grants the deploying principal needs).
|
||||||
|
report["iam_delta"] = [
|
||||||
|
"cloudformation:*",
|
||||||
|
"iam:CreateRole",
|
||||||
|
"iam:PassRole",
|
||||||
|
"lambda:CreateFunction",
|
||||||
|
"lambda:CreateFunctionUrlConfig",
|
||||||
|
"dynamodb:CreateTable",
|
||||||
|
"kms:CreateKey",
|
||||||
|
"kms:CreateAlias",
|
||||||
|
]
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def generate_and_deploy(
|
||||||
|
public_jwks_domain: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
approve_fn=None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""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:
|
||||||
|
public_jwks_domain: optional custom JWKS domain.
|
||||||
|
dry_run: if True, print the resource summary only (no deploy).
|
||||||
|
approve_fn: callable returning True/False for the y/N prompt
|
||||||
|
(defaults to stdin readline).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"template": <dict>, "summary": <dict>, "deployed": bool}``.
|
||||||
|
"""
|
||||||
|
template = generate_template(public_jwks_domain)
|
||||||
|
summary = resource_summary(template)
|
||||||
|
if dry_run:
|
||||||
|
return {"template": template, "summary": summary, "deployed": False}
|
||||||
|
warnings.warn(_CFN_ARCHIVE_REF, DeprecationWarning, stacklevel=2)
|
||||||
|
# NFR-10: explicit y/N approval before cloudformation deploy.
|
||||||
|
print("Resource summary:")
|
||||||
|
for rtype, count in sorted(summary.items()):
|
||||||
|
print(f" {rtype}: {count}")
|
||||||
|
# Print template to a temp file + open $PAGER.
|
||||||
|
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8")
|
||||||
|
json.dump(template, tmp, indent=2); tmp.flush(); tmp.close()
|
||||||
|
pager = os.environ.get("PAGER")
|
||||||
|
if pager and sys.stdin.isatty():
|
||||||
|
try:
|
||||||
|
subprocess.run([pager, tmp.name])
|
||||||
|
except Exception:
|
||||||
|
print(f"(template at {tmp.name})")
|
||||||
|
else:
|
||||||
|
print(f"(template at {tmp.name})")
|
||||||
|
# y/N prompt.
|
||||||
|
if approve_fn is None:
|
||||||
|
answer = input("Apply? [y/N] ").strip().lower()
|
||||||
|
else:
|
||||||
|
answer = "y" if approve_fn() else "n"
|
||||||
|
if answer != "y":
|
||||||
|
print("aborted (no approval)")
|
||||||
|
return {"template": template, "summary": summary, "deployed": False}
|
||||||
|
# cloudformation deploy.
|
||||||
|
stack_name = os.environ.get("NOVA_IDP_STACK_NAME", "nova-idp")
|
||||||
|
try:
|
||||||
|
subprocess.check_call([
|
||||||
|
"aws", "cloudformation", "deploy",
|
||||||
|
"--stack-name", stack_name,
|
||||||
|
"--template-file", tmp.name,
|
||||||
|
"--capabilities", "CAPABILITY_IAM",
|
||||||
|
])
|
||||||
|
deployed = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"deploy failed: {e}", file=sys.stderr)
|
||||||
|
deployed = False
|
||||||
|
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]:
|
||||||
|
"""Run the KMS round-trip verification (REQ-340 --verify).
|
||||||
|
|
||||||
|
Delegates to the CAP-037 test logic: sign a JWT (mock KMS) → JWKS →
|
||||||
|
pyjwt verify. Returns ``{"passed": bool, "detail": str}``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub_der = priv.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _MockKms:
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
return {"Signature": priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": pub_der}
|
||||||
|
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms())
|
||||||
|
token = kms_signing.sign_jwt({"sub": "verify", "exp": 9999999999, "iat": 1, "jti": "v"})
|
||||||
|
jwk = kms_signing.get_jwk()
|
||||||
|
key = pyjwt.PyJWK(jwk).key
|
||||||
|
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||||
|
ok = decoded["sub"] == "verify"
|
||||||
|
return {"passed": ok, "detail": "KMS round-trip OK" if ok else "mismatch"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"passed": False, "detail": f"verify error: {e}"}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "--check"
|
||||||
|
if mode == "--check":
|
||||||
|
print(json.dumps(check_prerequisites(), indent=2))
|
||||||
|
elif mode == "--dry-run":
|
||||||
|
print(json.dumps(generate_and_deploy(dry_run=True)["summary"], indent=2))
|
||||||
|
elif mode == "--verify":
|
||||||
|
print(json.dumps(verify(), indent=2))
|
||||||
|
else:
|
||||||
|
print("usage: nova_idp_setup.py --check|--dry-run|--verify", file=sys.stderr)
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Nova IdP token-vend Lambda — PAT/session → KMS-signed OIDC token
|
||||||
|
(REQ-336, C-6.1/C-7.1 ABAC FAIL-CLOSED, D-229 revocation).
|
||||||
|
|
||||||
|
Accepts a PAT (or session token) and returns a KMS-signed OIDC token
|
||||||
|
with claims ``sub, aud, iss, exp, iat, jti, roles`` (REQ-336).
|
||||||
|
|
||||||
|
## ABAC fail-closed (C-6.1/C-7.1 — INV-17 runtime enforcement)
|
||||||
|
|
||||||
|
The grill's #1 finding: the token-vend Lambda MUST fail closed on ABAC
|
||||||
|
evaluation failure. Concretely, a token is vended **only** when:
|
||||||
|
|
||||||
|
1. The PAT is active (``nova-pats.GetItem(jti, ConsistentRead=True)``
|
||||||
|
returns an item with ``status == "active"`` — D-229; strong read on
|
||||||
|
the main table, GSIs don't support strong reads).
|
||||||
|
2. ``KyvernoJsonEngine.is_configured()`` returns ``True`` **AND**
|
||||||
|
``evaluate_token_vend_policy()`` returns ``allowed=True`` without
|
||||||
|
raising.
|
||||||
|
|
||||||
|
If (2) fails for **any** reason — ``kj`` absent, ``kj`` error, policy
|
||||||
|
parse error, engine raise — the Lambda returns **403** + audit
|
||||||
|
``token.vend.denied`` (reason ``abac_eval_failed``). **Never fail
|
||||||
|
open.** This is verified by ``tests/test_abac_fail_closed.py`` — the
|
||||||
|
most important test of the milestone.
|
||||||
|
|
||||||
|
## Dual-use (REQ-329 pattern)
|
||||||
|
|
||||||
|
Mirrors ``nova_idp_auth.py``: lazy boto3, env-var table names,
|
||||||
|
``NOVA_LAMBDA_LOCAL_BYPASS``, ``__main__`` CLI block, audit emission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config (env-var table names, mirroring nova_idp_auth.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||||
|
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||||
|
OIDC_AUDIENCE = os.environ.get("NOVA_OIDC_AUDIENCE", "nova-cli")
|
||||||
|
# OIDC token lifetime (seconds). Default 15 min.
|
||||||
|
OIDC_TTL_SECONDS = int(os.environ.get("NOVA_OIDC_TTL_SECONDS", "900"))
|
||||||
|
|
||||||
|
_dynamodb = None
|
||||||
|
_kms_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamodb():
|
||||||
|
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||||
|
global _dynamodb
|
||||||
|
if _dynamodb is None:
|
||||||
|
_dynamodb = boto3.resource("dynamodb")
|
||||||
|
return _dynamodb
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_now() -> int:
|
||||||
|
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
"""Emit an audit event to stderr as JSON (never the raw PAT/token)."""
|
||||||
|
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||||
|
# Defense-in-depth: scrub raw token fields (INV-16/INV-17 spirit).
|
||||||
|
for _k in ("pat", "session_token", "token", "raw_pat"):
|
||||||
|
payload.pop(_k, None)
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PAT / session decoding (decode WITHOUT verifying — signature verified
|
||||||
|
# by KMS public key separately at the JWKS verifier; the revocation
|
||||||
|
# check is the trust anchor here, not the JWT signature).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_unverified(token: str) -> dict:
|
||||||
|
"""Decode a JWT's payload without verifying the signature."""
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
return pyjwt.decode(token, options={"verify_signature": False})
|
||||||
|
except Exception:
|
||||||
|
# Fallback: manual base64url decode of the payload segment.
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("malformed JWT (expected 3 segments)")
|
||||||
|
import base64
|
||||||
|
pad = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||||
|
return json.loads(base64.urlsafe_b64decode(pad))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pat_claims(token: str) -> dict:
|
||||||
|
"""Decode a PAT/session JWT → extract jti, sub, typ, roles, owner, exp."""
|
||||||
|
claims = _decode_jwt_unverified(token)
|
||||||
|
required = ("jti", "sub", "exp")
|
||||||
|
for f in required:
|
||||||
|
if f not in claims:
|
||||||
|
raise ValueError(f"token missing claim: {f}")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Revocation check (D-229 — strong read on the main table)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_pat_active(jti: str) -> tuple[bool, str]:
|
||||||
|
"""Return ``(active, reason)``. Strong read on nova-pats main table.
|
||||||
|
|
||||||
|
D-229: GSIs don't support strongly-consistent reads, so the
|
||||||
|
revocation check uses ``GetItem(PK=jti, ConsistentRead=True)`` on
|
||||||
|
the main table. This satisfies the 60s SLO synchronously (the
|
||||||
|
strong read reflects the latest write — revocation is instant).
|
||||||
|
"""
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
resp = table.get_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Key={"jti": jti},
|
||||||
|
ConsistentRead=True,
|
||||||
|
)
|
||||||
|
item = resp.get("Item")
|
||||||
|
if item is None:
|
||||||
|
return False, "pat_unknown"
|
||||||
|
status = item.get("status", "active")
|
||||||
|
if status != "active":
|
||||||
|
return False, f"pat_{status}" # pat_revoked, pat_expired, etc.
|
||||||
|
# Expired? (defense-in-depth; TTL may not have reaped it yet)
|
||||||
|
expires_at = item.get("expires_at")
|
||||||
|
if expires_at is not None:
|
||||||
|
try:
|
||||||
|
if int(expires_at) < _epoch_now():
|
||||||
|
return False, "pat_expired"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return True, "active"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ABAC fail-closed (C-6.1/C-7.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_abac_payload(claims: dict, requested_claims: list[str],
|
||||||
|
target_resource: dict, environment: str,
|
||||||
|
policy_version: str) -> dict:
|
||||||
|
"""Build the ABAC authorization payload (REQ-339, C-5.1)."""
|
||||||
|
return {
|
||||||
|
"subject": {
|
||||||
|
"id": claims.get("sub", ""),
|
||||||
|
"role": (claims.get("roles") or ["unknown"])[0],
|
||||||
|
"owner": claims.get("owner", ""),
|
||||||
|
},
|
||||||
|
"requested_claims": requested_claims,
|
||||||
|
"target_resource": target_resource,
|
||||||
|
"environment": environment,
|
||||||
|
"pat_jti": claims.get("jti", ""),
|
||||||
|
"policy_version": policy_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_abac_fail_closed(payload: dict) -> tuple[bool, list, str, str]:
|
||||||
|
"""Evaluate ABAC with fail-closed semantics (C-6.1).
|
||||||
|
|
||||||
|
Returns ``(allowed, pcrs, policy_sha, reason)``. On ANY failure
|
||||||
|
(engine not configured, evaluate raises, policy parse error) returns
|
||||||
|
``(False, [], "", "abac_eval_failed")``. **Never fails open.**
|
||||||
|
"""
|
||||||
|
# Lazy imports so the module imports without the engine adapter.
|
||||||
|
from core.policy_engine import get_engine
|
||||||
|
|
||||||
|
# C-6.1: is_configured() check. If kj is absent → fail closed.
|
||||||
|
try:
|
||||||
|
engine = get_engine()
|
||||||
|
if not engine.is_configured():
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.abac_engine_not_configured",
|
||||||
|
pat_jti=payload.get("pat_jti", ""),
|
||||||
|
)
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
except Exception: # noqa: BLE001 - fail closed on any engine check error
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
|
||||||
|
# C-6.1: evaluate() raising → fail closed.
|
||||||
|
try:
|
||||||
|
from core.abac_evaluator import evaluate_token_vend_policy
|
||||||
|
allowed, pcrs, policy_sha = evaluate_token_vend_policy(payload)
|
||||||
|
reason = "abac_denied" if not allowed else "ok"
|
||||||
|
return allowed, pcrs, policy_sha, reason
|
||||||
|
except Exception: # noqa: BLE001 - fail closed on any eval error
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token vend (REQ-336)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_oidc_claims(pat_claims: dict) -> dict:
|
||||||
|
"""Build the OIDC token claims (REQ-336)."""
|
||||||
|
now = _epoch_now()
|
||||||
|
return {
|
||||||
|
"sub": pat_claims["sub"],
|
||||||
|
"aud": OIDC_AUDIENCE,
|
||||||
|
"iss": OIDC_ISSUER,
|
||||||
|
"exp": now + OIDC_TTL_SECONDS,
|
||||||
|
"iat": now,
|
||||||
|
"jti": pat_claims.get("jti", ""), # carry the PAT jti for tracing
|
||||||
|
"roles": pat_claims.get("roles", []),
|
||||||
|
"typ": "nova_oidc_token", # INV-14: distinguish from developer_pat
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vend_token(
|
||||||
|
token: str,
|
||||||
|
requested_claims: list[str] | None = None,
|
||||||
|
target_resource: dict | None = None,
|
||||||
|
environment: str | None = None,
|
||||||
|
policy_version: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Vend a KMS-signed OIDC token for a PAT/session (REQ-336).
|
||||||
|
|
||||||
|
Returns ``{"token": ..., "expires_at": ...}`` on success. Raises
|
||||||
|
``_DeniedError`` (→ 403) on revocation / ABAC denial.
|
||||||
|
"""
|
||||||
|
requested_claims = requested_claims or ["sub", "roles"]
|
||||||
|
environment = environment or "dev"
|
||||||
|
|
||||||
|
# 1. Decode the PAT/session (without verifying — D-229).
|
||||||
|
pat_claims = _extract_pat_claims(token)
|
||||||
|
jti = pat_claims["jti"]
|
||||||
|
|
||||||
|
# Default target_resource: owner inherits from the PAT subject so
|
||||||
|
# the owner-matches ABAC rule passes for same-tenant vends. Callers
|
||||||
|
# can override with an explicit target_resource.
|
||||||
|
if target_resource is None:
|
||||||
|
target_resource = {
|
||||||
|
"type": "contract",
|
||||||
|
"id": "*",
|
||||||
|
"owner": pat_claims.get("owner", "*"),
|
||||||
|
"environment": environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Revocation check (D-229, strong read).
|
||||||
|
active, reason = _check_pat_active(jti)
|
||||||
|
if not active:
|
||||||
|
_emit_audit("token.vend.denied", pat_jti=jti, reason=reason)
|
||||||
|
raise _DeniedError(reason)
|
||||||
|
|
||||||
|
# 3. ABAC eval (C-6.1 FAIL-CLOSED).
|
||||||
|
abac_payload = _build_abac_payload(
|
||||||
|
pat_claims, requested_claims, target_resource, environment, policy_version
|
||||||
|
)
|
||||||
|
allowed, _pcrs, policy_sha, abac_reason = _evaluate_abac_fail_closed(abac_payload)
|
||||||
|
if not allowed:
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.denied",
|
||||||
|
pat_jti=jti,
|
||||||
|
reason=abac_reason,
|
||||||
|
policy_sha=policy_sha,
|
||||||
|
)
|
||||||
|
raise _DeniedError(abac_reason)
|
||||||
|
|
||||||
|
# 4. KMS sign (REQ-337).
|
||||||
|
from core.kms_signing import sign_jwt
|
||||||
|
oidc_claims = _build_oidc_claims(pat_claims)
|
||||||
|
oidc_token = sign_jwt(oidc_claims, key_id=OIDC_KMS_KEY_ID)
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.allowed",
|
||||||
|
pat_jti=jti,
|
||||||
|
sub=oidc_claims["sub"],
|
||||||
|
policy_sha=policy_sha,
|
||||||
|
expires_at=oidc_claims["exp"],
|
||||||
|
)
|
||||||
|
return {"token": oidc_token, "expires_at": oidc_claims["exp"]}
|
||||||
|
|
||||||
|
|
||||||
|
class _DeniedError(Exception):
|
||||||
|
"""Raised on revocation / ABAC denial → 403."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"token vend denied: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lambda handler + HTTP mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_http_response(result_or_error):
|
||||||
|
if isinstance(result_or_error, Exception):
|
||||||
|
if isinstance(result_or_error, _DeniedError):
|
||||||
|
return {
|
||||||
|
"statusCode": 403,
|
||||||
|
"body": json.dumps({"error": "token_vend_denied", "reason": result_or_error.reason}),
|
||||||
|
}
|
||||||
|
if isinstance(result_or_error, ValueError):
|
||||||
|
return {
|
||||||
|
"statusCode": 400,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use)."""
|
||||||
|
try:
|
||||||
|
body = event.get("body", "{}")
|
||||||
|
payload = json.loads(body) if isinstance(body, str) else body
|
||||||
|
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||||
|
if not token:
|
||||||
|
raise ValueError("missing field: token (or pat / session_token)")
|
||||||
|
result = vend_token(
|
||||||
|
token=token,
|
||||||
|
requested_claims=payload.get("requested_claims"),
|
||||||
|
target_resource=payload.get("target_resource"),
|
||||||
|
environment=payload.get("environment"),
|
||||||
|
policy_version=payload.get("policy_version", ""),
|
||||||
|
)
|
||||||
|
return _to_http_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _to_http_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI (dual-use, REQ-329 pattern)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main(argv=None):
|
||||||
|
"""CLI entry point for the token-vend Lambda (REQ-329 dual-use)."""
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||||
|
try:
|
||||||
|
if "--vend-stdin" in raw:
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
elif "--vend" in raw:
|
||||||
|
idx = raw.index("--vend")
|
||||||
|
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||||
|
if not path:
|
||||||
|
print("Usage: --vend <payload.json>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(path) as fh:
|
||||||
|
payload = json.loads(fh.read())
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Usage: python3 -m core.lambda.nova_idp_token_vend "
|
||||||
|
"--vend <payload.json> | --vend-stdin < <payload.json>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||||
|
if not token:
|
||||||
|
print("error: missing token in payload", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
result = vend_token(
|
||||||
|
token=token,
|
||||||
|
requested_claims=payload.get("requested_claims"),
|
||||||
|
target_resource=payload.get("target_resource"),
|
||||||
|
environment=payload.get("environment"),
|
||||||
|
policy_version=payload.get("policy_version", ""),
|
||||||
|
)
|
||||||
|
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
except _DeniedError as e:
|
||||||
|
sys.stderr.write(f"error: token vend denied ({e.reason})\n")
|
||||||
|
return 3 # 403-class
|
||||||
|
except ValueError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 1
|
||||||
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
|
sys.stderr.write(f"internal error: {e}\n")
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
|
sys.exit(cli_main())
|
||||||
@@ -54,7 +54,8 @@ def _init_store(db_path=None):
|
|||||||
confidence_band TEXT,
|
confidence_band TEXT,
|
||||||
hitl_block INTEGER,
|
hitl_block INTEGER,
|
||||||
cost_estimate_usd REAL,
|
cost_estimate_usd REAL,
|
||||||
decision_id TEXT
|
decision_id TEXT,
|
||||||
|
escalation_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fact_capability (
|
CREATE TABLE IF NOT EXISTS fact_capability (
|
||||||
@@ -110,7 +111,9 @@ def _init_store(db_path=None):
|
|||||||
confidence REAL,
|
confidence REAL,
|
||||||
alternatives TEXT,
|
alternatives TEXT,
|
||||||
human_override INTEGER,
|
human_override INTEGER,
|
||||||
|
escalation_reason TEXT,
|
||||||
outcome TEXT,
|
outcome TEXT,
|
||||||
|
backfilled_at TEXT,
|
||||||
event_time TEXT,
|
event_time TEXT,
|
||||||
PRIMARY KEY (decision_id)
|
PRIMARY KEY (decision_id)
|
||||||
);
|
);
|
||||||
@@ -217,14 +220,15 @@ def collect_run_manifests(db_path=None, runs_dir=None):
|
|||||||
INSERT OR REPLACE INTO fact_run
|
INSERT OR REPLACE INTO fact_run
|
||||||
(run_id, contract_id, environment, started_at, completed_at,
|
(run_id, contract_id, environment, started_at, completed_at,
|
||||||
exit_code, outcome, confidence_score, confidence_band,
|
exit_code, outcome, confidence_score, confidence_band,
|
||||||
hitl_block, cost_estimate_usd, decision_id)
|
hitl_block, cost_estimate_usd, decision_id, escalation_reason)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (run_id, manifest.get("contract_id", ""), manifest.get("environment", ""),
|
""", (run_id, manifest.get("contract_id", ""), manifest.get("environment", ""),
|
||||||
manifest.get("started_at", ""), manifest.get("completed_at", ""),
|
manifest.get("started_at", ""), manifest.get("completed_at", ""),
|
||||||
manifest.get("exit_code", 0), manifest.get("outcome", ""),
|
manifest.get("exit_code", 0), manifest.get("outcome", ""),
|
||||||
conf.get("score", 0), conf.get("band", ""),
|
conf.get("score", 0), conf.get("band", ""),
|
||||||
1 if hitl.get("block") else 0,
|
1 if hitl.get("block") else 0,
|
||||||
manifest.get("cost_estimate_usd", 0), manifest.get("decision_id", "")))
|
manifest.get("cost_estimate_usd", 0), manifest.get("decision_id", ""),
|
||||||
|
manifest.get("escalation_reason")))
|
||||||
count += 1
|
count += 1
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -232,7 +236,16 @@ def collect_run_manifests(db_path=None, runs_dir=None):
|
|||||||
|
|
||||||
|
|
||||||
def collect_decision_ledger(db_path=None, ledger_db=None):
|
def collect_decision_ledger(db_path=None, ledger_db=None):
|
||||||
"""Read the Decision Ledger SQLite → fact_decision."""
|
"""Read the Decision Ledger SQLite → fact_decision.
|
||||||
|
|
||||||
|
REQ-317: preserves a backfilled outcome. The ledger is append-only
|
||||||
|
and the `nova.ai.decision.made` event always carries outcome=pending
|
||||||
|
(it is emitted before apply). Once `outcome_backfill.backfill()` has
|
||||||
|
transitioned the `fact_decision` row to succeeded/failed, a re-run of
|
||||||
|
the collector must NOT clobber it back to pending. We therefore
|
||||||
|
coalesce: if the existing row has a non-pending outcome, keep it +
|
||||||
|
its backfilled_at; otherwise write pending (the event default).
|
||||||
|
"""
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = _STORE_PATH
|
db_path = _STORE_PATH
|
||||||
if ledger_db is None:
|
if ledger_db is None:
|
||||||
@@ -251,15 +264,27 @@ def collect_decision_ledger(db_path=None, ledger_db=None):
|
|||||||
payload = json.loads(payload_json)
|
payload = json.loads(payload_json)
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
decision_id = data.get("decision_id", run_id)
|
decision_id = data.get("decision_id", run_id)
|
||||||
|
# Preserve a backfilled outcome across collector re-runs (REQ-317).
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT outcome, backfilled_at FROM fact_decision WHERE decision_id = ?",
|
||||||
|
(decision_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing and existing[0] and existing[0] != "pending":
|
||||||
|
outcome = existing[0]
|
||||||
|
backfilled_at = existing[1]
|
||||||
|
else:
|
||||||
|
outcome = data.get("outcome", "pending")
|
||||||
|
backfilled_at = data.get("backfilled_at")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR REPLACE INTO fact_decision
|
INSERT OR REPLACE INTO fact_decision
|
||||||
(decision_id, run_id, chosen_action, confidence, alternatives,
|
(decision_id, run_id, chosen_action, confidence, alternatives,
|
||||||
human_override, outcome, event_time)
|
human_override, escalation_reason, outcome, backfilled_at, event_time)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (decision_id, run_id, data.get("chosen_action", ""),
|
""", (decision_id, run_id, data.get("chosen_action", ""),
|
||||||
data.get("confidence", 0), json.dumps(data.get("alternatives", {})),
|
data.get("confidence", 0), json.dumps(data.get("alternatives", {})),
|
||||||
1 if data.get("human_override") else 0,
|
1 if data.get("human_override") else 0,
|
||||||
data.get("outcome", "pending"), event_time))
|
data.get("escalation_reason"),
|
||||||
|
outcome, backfilled_at, event_time))
|
||||||
count += 1
|
count += 1
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -224,12 +224,16 @@ def replay_run(run_id, db_path=None):
|
|||||||
line = f" [{e['seq']}] {e['event_time']} {etype}"
|
line = f" [{e['seq']}] {e['event_time']} {etype}"
|
||||||
if etype == "nova.ai.decision.made":
|
if etype == "nova.ai.decision.made":
|
||||||
line += f" confidence={data.get('confidence', '?')} band={data.get('chosen_action', '?')} override={data.get('human_override', '?')}"
|
line += f" confidence={data.get('confidence', '?')} band={data.get('chosen_action', '?')} override={data.get('human_override', '?')}"
|
||||||
|
if data.get("escalation_reason"):
|
||||||
|
line += f" escalation_reason={data.get('escalation_reason')}"
|
||||||
elif etype == "nova.attestation.recorded":
|
elif etype == "nova.attestation.recorded":
|
||||||
line += f" env={data.get('environment', '?')} approver={data.get('approver', '?')} result={data.get('result', '?')}"
|
line += f" env={data.get('environment', '?')} approver={data.get('approver', '?')} result={data.get('result', '?')}"
|
||||||
elif etype == "nova.run.completed":
|
elif etype == "nova.run.completed":
|
||||||
line += f" exit={data.get('exit_code', '?')} outcome={data.get('outcome', '?')}"
|
line += f" exit={data.get('exit_code', '?')} outcome={data.get('outcome', '?')}"
|
||||||
elif etype == "nova.run.failed":
|
elif etype == "nova.run.failed":
|
||||||
line += f" exit={data.get('exit_code', '?')} outcome=failed"
|
line += f" exit={data.get('exit_code', '?')} outcome=failed"
|
||||||
|
elif etype == "nova.outcome.backfilled":
|
||||||
|
line += f" prev={data.get('previous_outcome', '?')} new={data.get('new_outcome', '?')} at={data.get('backfilled_at', '?')}"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
lines.append("=== End replay ===")
|
lines.append("=== End replay ===")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Nova Outcome Backfill (REQ-317, SPEC §5.8, P3 Wave 2).
|
||||||
|
|
||||||
|
The `fact_decision.outcome` column in the metrics cold store is written
|
||||||
|
`pending` by the collector (it ingests `nova.ai.decision.made` events,
|
||||||
|
which are emitted *before* the run executes the apply). Once the run
|
||||||
|
completes (`nova.run.completed`, exit 0) or fails (`nova.run.failed`,
|
||||||
|
exit non-zero), the outcome must be transitioned `pending ->
|
||||||
|
succeeded`/`failed` so the Post-Pilot AI Decision Accuracy denominator is
|
||||||
|
grounded (an outcome that is stuck `pending` cannot be scored).
|
||||||
|
|
||||||
|
Architecture (grounded in what the ledger + collector actually do):
|
||||||
|
|
||||||
|
* The Decision Ledger (`core/metrics/decision_ledger.py`) is an
|
||||||
|
**append-only hash-chain** of CloudEvents envelopes — there is no
|
||||||
|
`fact_decision` table *inside* the ledger DB; facts live in the
|
||||||
|
separate collector cold store (`core/metrics/collector.py`,
|
||||||
|
`nova_metrics.db`). The ledger is never UPDATEd in place (that would
|
||||||
|
break the SHA-256 chain — see `verify_chain()`).
|
||||||
|
* Therefore the backfill does TWO things:
|
||||||
|
1. Appends a new audit event `nova.outcome.backfilled` to the
|
||||||
|
ledger (preserves the hash chain; auditable via `replay_run`).
|
||||||
|
2. UPDATEs the `fact_decision` row in the cold store (the row is
|
||||||
|
keyed by `decision_id`; `outcome` + `backfilled_at` are
|
||||||
|
mutable — they are facts, not chain events).
|
||||||
|
|
||||||
|
Idempotent + terminal:
|
||||||
|
* If `outcome` is already `succeeded`/`failed` (i.e. not `pending`),
|
||||||
|
the call is a no-op and returns `{"status": "already_backfilled",
|
||||||
|
"existing_outcome": <current>}`. A terminal outcome is NEVER
|
||||||
|
overwritten (defense against double-backfill and against flipping a
|
||||||
|
`succeeded` run to `failed` retroactively or vice versa).
|
||||||
|
* The same `outcome` value is re-asserted harmlessly (still a no-op).
|
||||||
|
|
||||||
|
REQ-317: `outcome` ∈ {"succeeded", "failed"} only — `pending` is the
|
||||||
|
initial state and may not be written by the backfill (it would undo the
|
||||||
|
transition). An invalid value raises `ValueError`.
|
||||||
|
|
||||||
|
Future milestones may add `'policy'` to `escalation_reason` (REQ-318);
|
||||||
|
this module is scoped to outcome only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from core.metrics.event_envelope import make_event, append_event
|
||||||
|
from core.metrics.decision_ledger import append as ledger_append, _LEDGER_PATH
|
||||||
|
|
||||||
|
# The collector cold store path is mirrored here so the backfill can be
|
||||||
|
# invoked without importing the collector (avoids a circular import:
|
||||||
|
# the collector calls into backfill at run.completed/run.failed time).
|
||||||
|
_METRICS_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"metrics",
|
||||||
|
)
|
||||||
|
_STORE_PATH = os.path.join(_METRICS_DIR, "nova_metrics.db")
|
||||||
|
|
||||||
|
_VALID_OUTCOMES = {"succeeded", "failed"}
|
||||||
|
_PENDING = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now():
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_store_path(store_path: Optional[str | Path]) -> str:
|
||||||
|
if store_path is None:
|
||||||
|
return _STORE_PATH
|
||||||
|
return str(store_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ledger_path(ledger_path: Optional[str | Path]) -> str:
|
||||||
|
if ledger_path is None:
|
||||||
|
return _LEDGER_PATH
|
||||||
|
return str(ledger_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fact_decision(decision_id: str, store_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Read the fact_decision row for decision_id (or None)."""
|
||||||
|
if not os.path.isfile(store_path):
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT decision_id, run_id, chosen_action, confidence, alternatives, "
|
||||||
|
"human_override, outcome, event_time FROM fact_decision WHERE decision_id = ?",
|
||||||
|
(decision_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
decision_id: str,
|
||||||
|
outcome: str,
|
||||||
|
ledger_path: Optional[str | Path] = None,
|
||||||
|
store_path: Optional[str | Path] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Transition fact_decision.outcome from `pending` to `outcome`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: the decision id (== run_id for v1.26).
|
||||||
|
outcome: the terminal outcome; must be in {"succeeded", "failed"}.
|
||||||
|
ledger_path: optional override for the Decision Ledger SQLite DB.
|
||||||
|
store_path: optional override for the collector cold store SQLite DB.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict describing the result:
|
||||||
|
* success: {"status": "backfilled", "decision_id", "previous_outcome",
|
||||||
|
"new_outcome", "backfilled_at"}
|
||||||
|
* no-op: {"status": "already_backfilled", "decision_id",
|
||||||
|
"existing_outcome", "backfilled_at"}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if `outcome` is not in {"succeeded", "failed"}.
|
||||||
|
KeyError: if `decision_id` is not present in fact_decision.
|
||||||
|
"""
|
||||||
|
if outcome not in _VALID_OUTCOMES:
|
||||||
|
raise ValueError(
|
||||||
|
f"outcome must be one of {sorted(_VALID_OUTCOMES)}, got: {outcome!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sp = _resolve_store_path(store_path)
|
||||||
|
lp = _resolve_ledger_path(ledger_path)
|
||||||
|
|
||||||
|
existing = _get_fact_decision(decision_id, sp)
|
||||||
|
if existing is None:
|
||||||
|
raise KeyError(decision_id)
|
||||||
|
|
||||||
|
current_outcome = existing.get("outcome") or _PENDING
|
||||||
|
backfilled_at = _iso8601_now()
|
||||||
|
|
||||||
|
if current_outcome != _PENDING:
|
||||||
|
# Idempotent + terminal: do NOT overwrite a non-pending outcome.
|
||||||
|
return {
|
||||||
|
"status": "already_backfilled",
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"existing_outcome": current_outcome,
|
||||||
|
"backfilled_at": backfilled_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id = existing.get("run_id") or decision_id
|
||||||
|
|
||||||
|
# 1. UPDATE the fact_decision row in the cold store (mutable fact).
|
||||||
|
conn = sqlite3.connect(sp)
|
||||||
|
# Add backfilled_at column idempotently (schema was added in v1.26 P3 W2;
|
||||||
|
# older cold stores created by P2 lack it — ALTER TABLE is a no-op if
|
||||||
|
# the column already exists).
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE fact_decision ADD COLUMN backfilled_at TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # column already exists
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE fact_decision SET outcome = ?, backfilled_at = ? WHERE decision_id = ?",
|
||||||
|
(outcome, backfilled_at, decision_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 2. Append an audit event to the append-only Decision Ledger (preserves
|
||||||
|
# the hash chain — the ledger is never UPDATEd in place).
|
||||||
|
try:
|
||||||
|
backfill_data = {
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"previous_outcome": _PENDING,
|
||||||
|
"new_outcome": outcome,
|
||||||
|
"backfilled_at": backfilled_at,
|
||||||
|
}
|
||||||
|
event = make_event(
|
||||||
|
"nova.outcome.backfilled",
|
||||||
|
run_id,
|
||||||
|
existing.get("environment", ""),
|
||||||
|
backfill_data,
|
||||||
|
contract_id=existing.get("contract_id", ""),
|
||||||
|
actor_type="outcome-backfill",
|
||||||
|
actor_id="outcome_backfill",
|
||||||
|
)
|
||||||
|
append_event(event)
|
||||||
|
ledger_append(event, db_path=lp)
|
||||||
|
except Exception:
|
||||||
|
# Metrics emission must never break the backfill — the cold store
|
||||||
|
# UPDATE is the source of truth for the denominator; the ledger
|
||||||
|
# event is audit chrome.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "backfilled",
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"previous_outcome": _PENDING,
|
||||||
|
"new_outcome": outcome,
|
||||||
|
"backfilled_at": backfilled_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("usage: outcome_backfill.py <decision_id> <succeeded|failed>", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
_did = sys.argv[1]
|
||||||
|
_out = sys.argv[2]
|
||||||
|
try:
|
||||||
|
_r = backfill(_did, _out)
|
||||||
|
print(json.dumps(_r, indent=2))
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -27,6 +27,27 @@ def _iso8601_now():
|
|||||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_outcome(decision_id, outcome):
|
||||||
|
"""Transition fact_decision.outcome pending -> outcome (REQ-317).
|
||||||
|
|
||||||
|
Best-effort: logs a warning and skips if decision_id is missing or the
|
||||||
|
backfill raises. Never raises — the run is already completing/failing
|
||||||
|
and the manifest write is the source of truth for the run outcome.
|
||||||
|
"""
|
||||||
|
if not decision_id:
|
||||||
|
# A run that failed before ai.decision.made was emitted has no
|
||||||
|
# decision to backfill (e.g. a schema-validation failure). Skip
|
||||||
|
# silently rather than pollute stderr on every clean run.
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from core.metrics import outcome_backfill
|
||||||
|
return outcome_backfill.backfill(decision_id, outcome)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(f"[run_manifest] outcome backfill skipped for {decision_id}: {exc}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _run_id():
|
def _run_id():
|
||||||
return f"run-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
return f"run-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
@@ -44,7 +65,7 @@ def start_run(contract_id, environment, stages=None):
|
|||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
def complete_run(run_id, contract_id, environment, stages, exit_code, confidence=None, hitl=None, policy=None, cost_estimate_usd=None, decision_id=None):
|
def complete_run(run_id, contract_id, environment, stages, exit_code, confidence=None, hitl=None, policy=None, cost_estimate_usd=None, decision_id=None, escalation_reason=None):
|
||||||
"""Emit nova.run.completed + write the per-run manifest JSON.
|
"""Emit nova.run.completed + write the per-run manifest JSON.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -58,6 +79,10 @@ def complete_run(run_id, contract_id, environment, stages, exit_code, confidence
|
|||||||
policy: optional {passed, failed, skipped}
|
policy: optional {passed, failed, skipped}
|
||||||
cost_estimate_usd: optional float
|
cost_estimate_usd: optional float
|
||||||
decision_id: optional string (links to the Decision Ledger)
|
decision_id: optional string (links to the Decision Ledger)
|
||||||
|
escalation_reason: optional string (REQ-318) — "confidence" when
|
||||||
|
the ai.decision.made band was block; absent/None otherwise.
|
||||||
|
Persisted into the manifest so the collector can write it
|
||||||
|
into fact_run (Post-Pilot Human Escalation Frequency denom).
|
||||||
"""
|
"""
|
||||||
started_at = stages[0].get("started_at", _iso8601_now()) if stages else _iso8601_now()
|
started_at = stages[0].get("started_at", _iso8601_now()) if stages else _iso8601_now()
|
||||||
completed_at = _iso8601_now()
|
completed_at = _iso8601_now()
|
||||||
@@ -83,6 +108,8 @@ def complete_run(run_id, contract_id, environment, stages, exit_code, confidence
|
|||||||
manifest["cost_estimate_usd"] = cost_estimate_usd
|
manifest["cost_estimate_usd"] = cost_estimate_usd
|
||||||
if decision_id:
|
if decision_id:
|
||||||
manifest["decision_id"] = decision_id
|
manifest["decision_id"] = decision_id
|
||||||
|
if escalation_reason:
|
||||||
|
manifest["escalation_reason"] = escalation_reason
|
||||||
|
|
||||||
os.makedirs(_RUNS_DIR, exist_ok=True)
|
os.makedirs(_RUNS_DIR, exist_ok=True)
|
||||||
manifest_path = os.path.join(_RUNS_DIR, f"{run_id}.json")
|
manifest_path = os.path.join(_RUNS_DIR, f"{run_id}.json")
|
||||||
@@ -92,6 +119,14 @@ def complete_run(run_id, contract_id, environment, stages, exit_code, confidence
|
|||||||
event_type = "nova.run.completed" if exit_code == 0 else "nova.run.failed"
|
event_type = "nova.run.completed" if exit_code == 0 else "nova.run.failed"
|
||||||
emit(event_type, run_id, environment, manifest, contract_id=contract_id)
|
emit(event_type, run_id, environment, manifest, contract_id=contract_id)
|
||||||
|
|
||||||
|
# REQ-317: backfill fact_decision.outcome pending -> succeeded/failed
|
||||||
|
# after the run completes. The decision_id links the run to the
|
||||||
|
# Decision Ledger entry written by ai.decision.made. Best-effort: a
|
||||||
|
# run that failed before ai.decision.made was emitted has no
|
||||||
|
# decision_id and the backfill is a no-op (the run outcome is still
|
||||||
|
# captured in the manifest above).
|
||||||
|
backfill_result = _backfill_outcome(decision_id, outcome)
|
||||||
|
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Nova client-mode resolver (P1, REQ-327, D-226).
|
||||||
|
|
||||||
|
Priority: --mode flag → NOVA_CLIENT_MODE env → credential type → TTY.
|
||||||
|
No silent fallbacks: every return carries a non-empty selection_reason.
|
||||||
|
|
||||||
|
INV-13: invalid env values are ignored + warned, then fall through.
|
||||||
|
INV-14: credential_type developer_pat/nova_oidc_token + TTY →
|
||||||
|
interactive; + no-TTY → agent. TTY check is sys.stdin.isatty() (D-226).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger("nova.mode_resolver")
|
||||||
|
|
||||||
|
_VALID_MODES = ("agent", "interactive")
|
||||||
|
_CRED_MODE_TYPES = ("developer_pat", "nova_oidc_token")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mode(
|
||||||
|
flag: Optional[str] = None,
|
||||||
|
env_var: Optional[str] = None,
|
||||||
|
credential_type: Optional[str] = None,
|
||||||
|
stdin_isatty: bool = False,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""Return (mode, selection_reason) honoring D-226 priority."""
|
||||||
|
if flag is not None and flag in _VALID_MODES:
|
||||||
|
return flag, "flag"
|
||||||
|
if env_var is not None and env_var != "":
|
||||||
|
if env_var in _VALID_MODES:
|
||||||
|
return env_var, "env"
|
||||||
|
log.warning(
|
||||||
|
"NOVA_CLIENT_MODE=%r invalid (expected one of %s); ignoring",
|
||||||
|
env_var,
|
||||||
|
_VALID_MODES,
|
||||||
|
)
|
||||||
|
if credential_type in _CRED_MODE_TYPES:
|
||||||
|
mode = "interactive" if stdin_isatty else "agent"
|
||||||
|
return mode, f"credential:{credential_type}"
|
||||||
|
mode = "interactive" if stdin_isatty else "agent"
|
||||||
|
return mode, "tty"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_credential_type(path: Path) -> Optional[str]:
|
||||||
|
"""Read the active credential's type from ~/.nova/credentials.json."""
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
active_jti = data.get("active_credential_jti")
|
||||||
|
for cred in data.get("credentials", []) or []:
|
||||||
|
if cred.get("jti") == active_jti:
|
||||||
|
return cred.get("type")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mode_from_env(credential_type: Optional[str] = None) -> Tuple[str, str]:
|
||||||
|
"""Resolve mode using sys.argv, NOVA_CLIENT_MODE, credentials, and TTY.
|
||||||
|
|
||||||
|
Best-effort --mode scan of sys.argv (no full argparse); env var;
|
||||||
|
~/.nova/credentials.json active credential type; sys.stdin.isatty().
|
||||||
|
"""
|
||||||
|
flag: Optional[str] = None
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok == "--mode" and i + 1 < len(argv):
|
||||||
|
flag = argv[i + 1]
|
||||||
|
break
|
||||||
|
if tok.startswith("--mode="):
|
||||||
|
flag = tok.split("=", 1)[1]
|
||||||
|
break
|
||||||
|
env_var = os.environ.get("NOVA_CLIENT_MODE")
|
||||||
|
if env_var is not None and env_var == "":
|
||||||
|
env_var = ""
|
||||||
|
if credential_type is None:
|
||||||
|
cred_path = Path.home() / ".nova" / "credentials.json"
|
||||||
|
credential_type = _read_credential_type(cred_path)
|
||||||
|
return resolve_mode(
|
||||||
|
flag=flag,
|
||||||
|
env_var=env_var,
|
||||||
|
credential_type=credential_type,
|
||||||
|
stdin_isatty=sys.stdin.isatty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mode, reason = resolve_mode_from_env()
|
||||||
|
print(f"mode={mode} reason={reason}")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""PAT (personal access token) lifecycle — issue + revoke (REQ-342, REQ-343).
|
||||||
|
|
||||||
|
PATs are signed JWTs (``typ: "developer_pat"``, KMS-signed) that
|
||||||
|
authenticate a developer/service-account to the token-vend Lambda. Only
|
||||||
|
the **hash** is stored in ``nova-pats`` (REQ-343) — the raw PAT is
|
||||||
|
returned to the caller once and never persisted.
|
||||||
|
|
||||||
|
## Max TTL (C-6.2)
|
||||||
|
|
||||||
|
* developer: ≤ 24h (86400s)
|
||||||
|
* service-account: ≤ 1h (3600s)
|
||||||
|
|
||||||
|
Enforced in :func:`issue_pat` via the ``subject_type`` argument.
|
||||||
|
|
||||||
|
## DynamoDB schema (REQ-343)
|
||||||
|
|
||||||
|
* PK: ``jti`` (uuid4)
|
||||||
|
* GSI1: ``sub`` (list PATs for a user)
|
||||||
|
* GSI2: ``pat_hash`` (SHA-256 of the raw PAT for lookup)
|
||||||
|
* ``status``: ``active`` | ``revoked`` (revoked PATs retained for audit)
|
||||||
|
* ``expires_at``: epoch seconds (TTL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||||
|
|
||||||
|
# C-6.2 max TTLs (seconds).
|
||||||
|
MAX_TTL_DEV = 24 * 3600 # 24h
|
||||||
|
MAX_TTL_SERVICE = 3600 # 1h
|
||||||
|
|
||||||
|
_dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamodb():
|
||||||
|
global _dynamodb
|
||||||
|
if _dynamodb is None:
|
||||||
|
_dynamodb = boto3.resource("dynamodb")
|
||||||
|
return _dynamodb
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_now() -> int:
|
||||||
|
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||||
|
for _k in ("pat", "raw_pat"):
|
||||||
|
payload.pop(_k, None)
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _max_ttl(subject_type: str) -> int:
|
||||||
|
if subject_type == "service-account":
|
||||||
|
return MAX_TTL_SERVICE
|
||||||
|
return MAX_TTL_DEV
|
||||||
|
|
||||||
|
|
||||||
|
def issue_pat(
|
||||||
|
subject: str,
|
||||||
|
roles: list[str],
|
||||||
|
owner: str,
|
||||||
|
ttl_seconds: int,
|
||||||
|
key_id: str = OIDC_KMS_KEY_ID,
|
||||||
|
subject_type: str = "developer",
|
||||||
|
claims: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Issue a PAT (signed JWT) + store its hash in nova-pats (REQ-342).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: the subject (user_id).
|
||||||
|
roles: the roles to embed in the PAT.
|
||||||
|
owner: the tenant owner.
|
||||||
|
ttl_seconds: requested TTL. Clamped to the C-6.2 max for
|
||||||
|
``subject_type`` (24h dev, 1h service-account).
|
||||||
|
key_id: KMS key ID/alias.
|
||||||
|
subject_type: ``"developer"`` or ``"service-account"``.
|
||||||
|
claims: extra claims to embed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The raw PAT JWT string (returned once; only the hash is stored).
|
||||||
|
"""
|
||||||
|
max_ttl = _max_ttl(subject_type)
|
||||||
|
if ttl_seconds > max_ttl:
|
||||||
|
ttl_seconds = max_ttl
|
||||||
|
if ttl_seconds < 1:
|
||||||
|
raise ValueError("ttl_seconds must be >= 1")
|
||||||
|
|
||||||
|
jti = str(uuid.uuid4())
|
||||||
|
now = _epoch_now()
|
||||||
|
exp = now + ttl_seconds
|
||||||
|
pat_claims = {
|
||||||
|
"iss": OIDC_ISSUER,
|
||||||
|
"sub": subject,
|
||||||
|
"typ": "developer_pat",
|
||||||
|
"jti": jti,
|
||||||
|
"iat": now,
|
||||||
|
"exp": exp,
|
||||||
|
"roles": roles,
|
||||||
|
"owner": owner,
|
||||||
|
}
|
||||||
|
if claims:
|
||||||
|
pat_claims.update(claims)
|
||||||
|
|
||||||
|
from core.kms_signing import sign_jwt
|
||||||
|
pat_jwt = sign_jwt(pat_claims, key_id=key_id)
|
||||||
|
|
||||||
|
# Only the hash is stored (REQ-343) — NOT the raw PAT.
|
||||||
|
pat_hash = hashlib.sha256(pat_jwt.encode("ascii")).hexdigest()
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
table.put_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Item={
|
||||||
|
"jti": jti,
|
||||||
|
"sub": subject,
|
||||||
|
"pat_hash": pat_hash,
|
||||||
|
"status": "active",
|
||||||
|
"issued_at": _iso8601_now(),
|
||||||
|
"expires_at": str(exp),
|
||||||
|
"subject_type": subject_type,
|
||||||
|
"claims": json.dumps(pat_claims),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_audit("pat.issued", jti=jti, sub=subject, subject_type=subject_type, ttl=ttl_seconds)
|
||||||
|
return pat_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_pat(jti: str) -> dict:
|
||||||
|
"""Revoke a PAT (D-229, REQ-342). Revoked PATs retained for audit.
|
||||||
|
|
||||||
|
Returns the update response. Audit ``pat.revoked`` emitted.
|
||||||
|
"""
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
resp = table.update_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Key={"jti": jti},
|
||||||
|
UpdateExpression="SET #s = :rev, revoked_at = :now",
|
||||||
|
ExpressionAttributeNames={"#s": "status"},
|
||||||
|
ExpressionAttributeValues={":rev": "revoked", ":now": _iso8601_now()},
|
||||||
|
)
|
||||||
|
_emit_audit("pat.revoked", jti=jti)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
print("use nova/auth/login.py and nova/auth/revoke.py", file=sys.stderr)
|
||||||
@@ -601,16 +601,17 @@ def _check_cap_024_deck_structure() -> Tuple[Status, str]:
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
deck_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
deck_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
"docs", "presentations", "nova-autonomous-cloud-delivery.md")
|
"docs", "presentations", "nova-autonomous-cloud-delivery-marp.md")
|
||||||
if not os.path.isfile(deck_path):
|
if not os.path.isfile(deck_path):
|
||||||
return "Skipped", "unified deck not found"
|
return "Skipped", "unified deck not found"
|
||||||
with open(deck_path) as f:
|
with open(deck_path) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
slide_count = content.count("## Slide ")
|
slide_count = content.count("## Slide ")
|
||||||
if slide_count < 18 or slide_count > 19:
|
if slide_count < 18 or slide_count > 20:
|
||||||
return "Broken", f"deck has {slide_count} main slides (expected 18-19)"
|
return "Broken", f"deck has {slide_count} main slides (expected 18-20)"
|
||||||
has_recap = "Recap + Ask" in content
|
has_recap = "Recap + Ask" in content
|
||||||
has_benefit = content.count("Benefit:") >= 10
|
benefit_count = content.count("Benefit:") + content.count('class="benefit"')
|
||||||
|
has_benefit = benefit_count >= 10
|
||||||
if not (has_recap and has_benefit):
|
if not (has_recap and has_benefit):
|
||||||
missing = []
|
missing = []
|
||||||
if not has_recap: missing.append("recap+ask")
|
if not has_recap: missing.append("recap+ask")
|
||||||
@@ -619,6 +620,98 @@ def _check_cap_024_deck_structure() -> Tuple[Status, str]:
|
|||||||
return "Verified", f"deck has {slide_count} slides, recap+ask present, per-slide benefits present"
|
return "Verified", f"deck has {slide_count} slides, recap+ask present, per-slide benefits present"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cap_025_live_pilot_apply() -> Tuple[Status, str]:
|
||||||
|
"""CAP-025 (REQ-316): live-pilot-apply pipeline readiness — structural
|
||||||
|
check that the pilot-apply end-to-end pipeline is wired (NOT a live
|
||||||
|
apply; the live apply lands in P4).
|
||||||
|
|
||||||
|
The pilot-apply round-trip is:
|
||||||
|
contract resolve -> adapter compile -> terraform plan -> policy scan
|
||||||
|
-> confidence signal -> terraform apply -> outbox write
|
||||||
|
|
||||||
|
For P3 this is a LOCAL-tier structural-readiness check: the scripts
|
||||||
|
exist + are wired, the core pipeline modules import, the pilot env is
|
||||||
|
bound to a real account (D-203), the DynamoDB L1 primitive is
|
||||||
|
registered (REQ-322), the pilot policies are authored (REQ-315/320),
|
||||||
|
and the outcome-backfill module exists (REQ-317). The live apply
|
||||||
|
against AWS is P4's live-verify (D-093 / G-111 steady state aside).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 1. scripts/run_platform.sh exists + contains the pipeline step markers.
|
||||||
|
run_platform = ROOT / "scripts" / "run_platform.sh"
|
||||||
|
if not run_platform.is_file():
|
||||||
|
return "Broken", "scripts/run_platform.sh missing (pilot-apply pipeline driver)"
|
||||||
|
script_text = run_platform.read_text()
|
||||||
|
# Step markers mirrored from the script's own comments + Step headers.
|
||||||
|
required_markers = [
|
||||||
|
"resolve contract", # Step 2: contract_resolver
|
||||||
|
"adapter compiles stack", # Step 3: terraform adapter
|
||||||
|
"terraform init", # Step 4: terraform plan
|
||||||
|
"terraform plan", # Step 4: terraform plan
|
||||||
|
"policy scan", # Step 5: runtime policy scan (Wiz/Checkov)
|
||||||
|
"confidence signal", # Step 7: confidence_signal compute
|
||||||
|
"terraform apply", # Step 5: terraform apply (--apply mode)
|
||||||
|
"outbox", # outbox write (Step 8)
|
||||||
|
]
|
||||||
|
missing_markers = [m for m in required_markers if m not in script_text]
|
||||||
|
if missing_markers:
|
||||||
|
return "Broken", f"run_platform.sh missing step markers: {missing_markers}"
|
||||||
|
|
||||||
|
# 2. core pipeline modules importable.
|
||||||
|
for mod_name in (
|
||||||
|
"core.contract_resolver",
|
||||||
|
"adapters.terraform.adapter",
|
||||||
|
"core.confidence_signal",
|
||||||
|
"core.outbox_writer",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
importlib.import_module(mod_name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "Broken", f"pipeline module not importable: {mod_name} ({type(exc).__name__}: {exc})"[:200]
|
||||||
|
|
||||||
|
# 3. dev env bound to the real pilot account (D-203).
|
||||||
|
dev_env_path = ROOT / "core" / "environments" / "dev.json"
|
||||||
|
if not dev_env_path.is_file():
|
||||||
|
return "Broken", "core/environments/dev.json missing"
|
||||||
|
try:
|
||||||
|
dev_env = json.loads(dev_env_path.read_text())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "Broken", f"dev.json parse failed: {exc}"[:200]
|
||||||
|
account_id = dev_env.get("account_id")
|
||||||
|
if account_id != "581513795199":
|
||||||
|
return "Broken", f"dev env not bound to real account (D-203): account_id={account_id!r}"
|
||||||
|
|
||||||
|
# 4. DynamoDB L1 primitive registered (REQ-322).
|
||||||
|
registry_path = ROOT / "modules" / "registry.json"
|
||||||
|
if not registry_path.is_file():
|
||||||
|
return "Broken", "modules/registry.json missing"
|
||||||
|
try:
|
||||||
|
registry = json.loads(registry_path.read_text())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "Broken", f"registry.json parse failed: {exc}"[:200]
|
||||||
|
if "dynamodb" not in registry:
|
||||||
|
return "Broken", "dynamodb L1 primitive not registered (REQ-322)"
|
||||||
|
|
||||||
|
# 5. pilot policies authored (REQ-315/320).
|
||||||
|
pilot_policies = [
|
||||||
|
ROOT / "adapters" / "kyverno-json" / "policies" / "pilot-readiness" / "no-placeholder-account.json",
|
||||||
|
ROOT / "adapters" / "kyverno-json" / "policies" / "settlement-finality" / "all-matches-committed.json",
|
||||||
|
]
|
||||||
|
missing_policies = [str(p.relative_to(ROOT)) for p in pilot_policies if not p.is_file()]
|
||||||
|
if missing_policies:
|
||||||
|
return "Broken", f"pilot policies not authored (REQ-315/320): {missing_policies}"
|
||||||
|
|
||||||
|
# 6. outcome-backfill module exists (REQ-317).
|
||||||
|
outcome_backfill = ROOT / "core" / "metrics" / "outcome_backfill.py"
|
||||||
|
if not outcome_backfill.is_file():
|
||||||
|
return "Broken", "outcome backfill not implemented (REQ-317)"
|
||||||
|
|
||||||
|
return ("Verified",
|
||||||
|
"pilot-apply pipeline structurally ready "
|
||||||
|
"(contract->adapter->plan->policy->confidence->apply->outbox)")
|
||||||
|
|
||||||
|
|
||||||
# Registry: ordered, each entry is (capability_id, name, tier, check_fn).
|
# Registry: ordered, each entry is (capability_id, name, tier, check_fn).
|
||||||
# Phase 52 seeds this with 10 local-tier checks; Phase 54 expands it to
|
# Phase 52 seeds this with 10 local-tier checks; Phase 54 expands it to
|
||||||
# cover every v1.1->v1.8 advertised capability and adds the live-AWS tier
|
# cover every v1.1->v1.8 advertised capability and adds the live-AWS tier
|
||||||
@@ -672,6 +765,8 @@ CAPABILITY_REGISTRY: List[Tuple[str, str, str, Callable[[], Tuple[Status, str]]]
|
|||||||
_check_cap_023_metrics_collector),
|
_check_cap_023_metrics_collector),
|
||||||
("CAP-024", "unified deck structure (slide count, x3, per-slide benefits)", "local",
|
("CAP-024", "unified deck structure (slide count, x3, per-slide benefits)", "local",
|
||||||
_check_cap_024_deck_structure),
|
_check_cap_024_deck_structure),
|
||||||
|
("CAP-025", "live-pilot-apply pipeline readiness (contract->apply->outbox)", "local",
|
||||||
|
_check_cap_025_live_pilot_apply),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-3
@@ -19,7 +19,7 @@ numbers. Every metric either has a real source or is explicitly deferred.
|
|||||||
|
|
||||||
### Touchless Resolution Rate
|
### Touchless Resolution Rate
|
||||||
- **Target:** ≥ 99% across production estates (Post-Pilot)
|
- **Target:** ≥ 99% across production estates (Post-Pilot)
|
||||||
- **Status:** partial (pipeline grounded; denominator = 0 today)
|
- **Status:** partial (pipeline grounded; denominator = 1 run post-pilot)
|
||||||
- **Formula:** runs completing without *operational* HITL block ÷ total runs
|
- **Formula:** runs completing without *operational* HITL block ÷ total runs
|
||||||
(attestation gates excluded — they're designed controls, not escalations)
|
(attestation gates excluded — they're designed controls, not escalations)
|
||||||
- **Source:** `metrics/nova_metrics.db` `fact_run` (hitl_block column)
|
- **Source:** `metrics/nova_metrics.db` `fact_run` (hitl_block column)
|
||||||
@@ -27,20 +27,54 @@ numbers. Every metric either has a real source or is explicitly deferred.
|
|||||||
|
|
||||||
### Human Escalation Frequency
|
### Human Escalation Frequency
|
||||||
- **Target:** < 0.1% of platform actions (Post-Pilot)
|
- **Target:** < 0.1% of platform actions (Post-Pilot)
|
||||||
- **Status:** partial (pipeline grounded; denominator = 0 today)
|
- **Status:** partial (pipeline grounded; denominator = 1 run post-pilot, 0 escalations)
|
||||||
- **Formula:** operational HITL blocks ÷ total runs (attestation sign-offs
|
- **Formula:** operational HITL blocks ÷ total runs (attestation sign-offs
|
||||||
excluded)
|
excluded)
|
||||||
- **Source:** `metrics/nova_metrics.db` `fact_run` (hitl_block column)
|
- **Source:** `metrics/nova_metrics.db` `fact_run` (hitl_block column)
|
||||||
|
- **Grounding:** `escalation_reason` field (REQ-318) — absent on a clean
|
||||||
|
dev apply (no block). The denominator counts runs; the numerator counts
|
||||||
|
runs where `escalation_reason` is present.
|
||||||
- **Definition-of-success:** `docs/metrics/human_escalation_frequency.md`
|
- **Definition-of-success:** `docs/metrics/human_escalation_frequency.md`
|
||||||
|
|
||||||
### AI Decision Accuracy
|
### AI Decision Accuracy
|
||||||
- **Target:** ≥ 99.5% (no rollback, no follow-up incident within 5 min)
|
- **Target:** ≥ 99.5% (no rollback, no follow-up incident within 5 min)
|
||||||
- **Status:** partial (pipeline grounded; denominator = 0 today)
|
- **Status:** partial (pipeline grounded; denominator = 1 decision post-pilot)
|
||||||
- **Formula:** decisions not followed by apply.failed/incident within 5min
|
- **Formula:** decisions not followed by apply.failed/incident within 5min
|
||||||
÷ total decisions
|
÷ total decisions
|
||||||
- **Source:** `metrics/nova_metrics.db` `fact_decision` (outcome column)
|
- **Source:** `metrics/nova_metrics.db` `fact_decision` (outcome column)
|
||||||
|
- **Grounding:** `fact_decision.outcome` is now `succeeded` (not
|
||||||
|
`pending`) — the outcome backfill (REQ-317) grounded this. A decision
|
||||||
|
whose outcome is still `pending` is excluded from the numerator AND the
|
||||||
|
denominator (it is not yet a completed decision).
|
||||||
- **Definition-of-success:** `docs/metrics/ai_decision_accuracy.md`
|
- **Definition-of-success:** `docs/metrics/ai_decision_accuracy.md`
|
||||||
|
|
||||||
|
#### Post-Pilot Activation (v1.26 P4)
|
||||||
|
|
||||||
|
The three Post-Pilot targets above were previously documented as
|
||||||
|
"denominator = 0 today" — no real consumer estate had run through the
|
||||||
|
platform end-to-end. The v1.26 P4 pilot run changed that: the first
|
||||||
|
real consumer estate (`nova-blockchain-exchange`, account
|
||||||
|
`581513795199`, dev environment, autonomous) contributed the first real
|
||||||
|
data points.
|
||||||
|
|
||||||
|
- **Run id:** `blkex-pilot-apply-v0.2` (2026-08-19)
|
||||||
|
- **AI Decision Accuracy:** 1 decision (`blkex-pilot-apply-v0.2`),
|
||||||
|
outcome `pending → succeeded` (REQ-317 backfill). Numerator = 1
|
||||||
|
(no apply.failed, no incident), denominator = 1. Future runs
|
||||||
|
accumulate into this denominator.
|
||||||
|
- **Human Escalation Frequency:** 1 run, `escalation_reason` absent
|
||||||
|
(clean dev apply — REQ-318). Numerator = 0 escalations, denominator
|
||||||
|
= 1.
|
||||||
|
- **Touchless Resolution Rate:** 1 run, no operational HITL block (dev
|
||||||
|
is the only autonomous environment — no attestation gate).
|
||||||
|
Numerator = 1, denominator = 1.
|
||||||
|
|
||||||
|
The denominators are now non-zero. Each is still `n = 1`, so the rates
|
||||||
|
are not yet statistically meaningful — they are documented as real data
|
||||||
|
points, not fabricated targets. See `.ciagent/P4-PILOT-RUN-EVIDENCE.md`
|
||||||
|
for the full evidence stream (confidence 0.800 pass, Decision Ledger
|
||||||
|
hash chain valid).
|
||||||
|
|
||||||
### MTTD / MTTR (platform-run)
|
### MTTD / MTTR (platform-run)
|
||||||
- **Target:** < 60 seconds (p95)
|
- **Target:** < 60 seconds (p95)
|
||||||
- **Status:** grounded (platform-run MTTR)
|
- **Status:** grounded (platform-run MTTR)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# CodeArtifact Provisioning — Status + Fallback (REQ-323, CAP-035)
|
||||||
|
|
||||||
|
> Phase P1 (cli-substrate), milestone v1.28. Owner: backend-engineer.
|
||||||
|
> This document records the CodeArtifact provisioning check outcome for
|
||||||
|
> the `nova-cli` wheel + Lambda layer publish pipeline (REQ-323), the
|
||||||
|
> required IAM grants, and the fallback wheel-index mode the publish
|
||||||
|
> workflow supports when CodeArtifact is not yet provisioned.
|
||||||
|
|
||||||
|
## 1. Provisioning check (best-effort, P1 Wave 4 gate)
|
||||||
|
|
||||||
|
**Target account:** `581513795199` (the Nova platform account).
|
||||||
|
|
||||||
|
**Attempted commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws codeartifact list-domains --region us-east-1
|
||||||
|
aws codeartifact describe-repository --domain nova --repository nova-pypi --region us-east-1
|
||||||
|
aws codeartifact list-repositories --domain nova --region us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** the check could not complete — no AWS credentials were
|
||||||
|
available in the P1 execute environment (`Unable to locate credentials.
|
||||||
|
You can configure credentials by running `aws configure`.`). This is
|
||||||
|
the "fail gracefully" path documented in the task spec: provisioning is
|
||||||
|
**not attempted** from this environment because the required IAM grants
|
||||||
|
are not confirmed for the execute principal.
|
||||||
|
|
||||||
|
**Classification:** P1 blocker for the CodeArtifact mode of the publish
|
||||||
|
workflow's wheel-upload step. The workflow ships with a fallback mode
|
||||||
|
(see §3) so the pipeline is not blocked on CodeArtifact provisioning —
|
||||||
|
it can publish to a private wheel index instead.
|
||||||
|
|
||||||
|
## 2. Required IAM grants (for a follow-up provisioning task)
|
||||||
|
|
||||||
|
To provision + use CodeArtifact as the wheel index, the principal that
|
||||||
|
runs the publish workflow (OIDC role `nova-publish-*` or the spike
|
||||||
|
runner) needs the following grants in account `581513795199`:
|
||||||
|
|
||||||
|
| Action | Scope (example) | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `codeartifact:CreateDomain` | `arn:aws:codeartifact:us-east-1:581513795199:domain/nova` | create the `nova` domain |
|
||||||
|
| `codeartifact:CreateRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/*` | create `nova-pypi` (pypi-format) |
|
||||||
|
| `codeartifact:GetRepositoryEndpoint` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | get the twine/pip endpoint |
|
||||||
|
| `codeartifact:GetAuthorizationToken` | `arn:aws:codeartifact:us-east-1:581513795199:domain/nova/*` | mint short-lived upload token |
|
||||||
|
| `codeartifact:ReadFromRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | pip install (consumers + the composite action) |
|
||||||
|
| `codeartifact:PublishPackageToRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | twine upload |
|
||||||
|
| `ssm:PutParameter` / `ssm:GetParameter` | `arn:aws:ssm:us-east-1:581513795199:parameter/nova/layer/*` | CAP-035 version↔ARN mapping |
|
||||||
|
| `lambda:PublishLayerVersion` | `arn:aws:lambda:us-east-1:581513795199:layer:nova-cli` | Lambda layer publish |
|
||||||
|
| `iam:CreateRole` / `iam:PassRole` (already held) | — | only if a dedicated publish OIDC role must be created |
|
||||||
|
|
||||||
|
The domain + repository to provision:
|
||||||
|
|
||||||
|
- **Domain:** `nova`
|
||||||
|
- **Repository:** `nova-pypi` (format: `pypi`)
|
||||||
|
- **Endpoint (twine/pip):**
|
||||||
|
`https://nova-581513795199.d.codeartifact.us-east-1.amazonaws.com/pypi/nova-pypi/`
|
||||||
|
|
||||||
|
Once provisioned, set the repository secret `NOVA_CODEARTIFACT_DOMAIN=nova`
|
||||||
|
on both forges and the publish workflow + composite action will switch
|
||||||
|
to CodeArtifact mode automatically (see §3).
|
||||||
|
|
||||||
|
## 3. Fallback: private wheel index (`NOVA_WHEEL_INDEX`)
|
||||||
|
|
||||||
|
Both the publish workflow (`.github/workflows/publish.yml` and its
|
||||||
|
byte-identical mirror on the dev forge) and the composite action
|
||||||
|
(`.github/actions/nova-cli/action.yml`) support a **fallback mode** that
|
||||||
|
does not require CodeArtifact. The selection is env/secret driven:
|
||||||
|
|
||||||
|
| Mode | Trigger | Upload target | Install source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **CodeArtifact** | `NOVA_CODEARTIFACT_DOMAIN` env/secret is set | `aws codeartifact login --tool twine` → twine uploads to the CodeArtifact pypi endpoint | `aws codeartifact login --tool pip` → `pip install nova==<ver>` |
|
||||||
|
| **Fallback index** | `NOVA_CODEARTIFACT_DOMAIN` unset; `TWINE_REPOSITORY_URL` + `TWINE_USERNAME` + `TWINE_PASSWORD` set | `twine upload` to `TWINE_REPOSITORY_URL` | `pip install --index-url $NOVA_WHEEL_INDEX nova==<ver>` |
|
||||||
|
|
||||||
|
The fallback index can be any PEP 503-compliant simple index — e.g. a
|
||||||
|
private package registry hosted on the dev forge, a self-hosted
|
||||||
|
`pypiserver`, or a static S3-backed index. The workflow does not hardcode
|
||||||
|
the index URL; it is supplied via the `NOVA_WHEEL_INDEX` env var (for
|
||||||
|
consumers / the composite action) and `TWINE_REPOSITORY_URL` (for the
|
||||||
|
publish step). This keeps the forge/registry choice deployment-specific
|
||||||
|
and avoids baking any single hostname into the synced workflow files.
|
||||||
|
|
||||||
|
### 3.1 Fallback index shape (when self-hosted)
|
||||||
|
|
||||||
|
A minimal PEP 503 simple index served from a private registry is
|
||||||
|
sufficient. The only required layout per package:
|
||||||
|
|
||||||
|
```
|
||||||
|
/nova/
|
||||||
|
index.html # links to each version's page
|
||||||
|
/nova-<version>-py3-none-any.whl # the wheel (publish workflow uploads this)
|
||||||
|
```
|
||||||
|
|
||||||
|
The publish workflow uploads `dist/nova-<version>-*.whl` via `twine
|
||||||
|
upload` to `TWINE_REPOSITORY_URL`; consumers install via
|
||||||
|
`pip install --index-url "$NOVA_WHEEL_INDEX" nova==<version>`.
|
||||||
|
|
||||||
|
## 4. CAP-035 invariant (unaffected by the index choice)
|
||||||
|
|
||||||
|
Regardless of which wheel index is used, the Lambda layer ARN ↔ wheel
|
||||||
|
version mapping is recorded in SSM and is the source of truth for
|
||||||
|
CAP-035:
|
||||||
|
|
||||||
|
```
|
||||||
|
/nova/layer/nova-cli/version = "<wheel-version>:<layer-arn>"
|
||||||
|
```
|
||||||
|
|
||||||
|
e.g. `1.14.0:arn:aws:lambda:us-east-1:581513795199:layer:nova-cli:3`.
|
||||||
|
The publish workflow writes this parameter atomically after both the
|
||||||
|
wheel upload and the layer publish succeed; if either fails the job
|
||||||
|
fails (merge blocked, REQ-323 AC).
|
||||||
|
|
||||||
|
## 5. Open follow-ups
|
||||||
|
|
||||||
|
1. Provision CodeArtifact domain `nova` + repository `nova-pypi` in
|
||||||
|
`581513795199` once the `codeartifact:*` grants in §2 are attached to
|
||||||
|
the publish OIDC role. Update this document with the confirmed ARN +
|
||||||
|
endpoint.
|
||||||
|
2. Set the `NOVA_CODEARTIFACT_DOMAIN` repository secret on both forges
|
||||||
|
to switch the publish workflow + composite action from fallback-index
|
||||||
|
mode to CodeArtifact mode.
|
||||||
|
3. Until §1 is done, the fallback index must be provisioned out of band
|
||||||
|
and its URL exposed to consumers via the `NOVA_WHEEL_INDEX` env var
|
||||||
|
(and to the publish workflow via the `TWINE_*` secrets).
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Developer Guide — Nova Auth (`nova auth`)
|
||||||
|
|
||||||
|
> **REQ-346** — developer guide for `nova auth login`. Covers signup,
|
||||||
|
> signin, login, mode resolution, TTY vs piped stdout behavior, and the
|
||||||
|
> JWS-from-PAT KDF (REQ-332, C-5.2).
|
||||||
|
>
|
||||||
|
> Audience: developers using the Nova CLI to authenticate and run
|
||||||
|
> `nova apply`. For operator-side identity stack deployment, see
|
||||||
|
> `docs/operator-guide-idp.md`.
|
||||||
|
|
||||||
|
## 1. Quickstart (5 steps)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Sign up (one-time per user).
|
||||||
|
nova auth signup --email alice@example.com --owner team-a
|
||||||
|
|
||||||
|
# 2. Sign in (returns a session — valid 24h).
|
||||||
|
nova auth signin --email alice@example.com
|
||||||
|
|
||||||
|
# 3. Issue a PAT and log in (session → OIDC token, stored locally).
|
||||||
|
nova auth login --pat <PAT>
|
||||||
|
|
||||||
|
# 4. Initialize a project (one-time per repo).
|
||||||
|
nova init
|
||||||
|
|
||||||
|
# 5. Apply locally + sign a local-review attestation.
|
||||||
|
nova apply --local --sign-local-review --contract .nova/contract.yml --pat <PAT>
|
||||||
|
```
|
||||||
|
|
||||||
|
After step 3, `~/.nova/credentials.json` holds your active OIDC token
|
||||||
|
(see §4). After step 5, the attestation is a JWS verifiable with the
|
||||||
|
PAT-derived key (see §7).
|
||||||
|
|
||||||
|
## 2. `nova auth signup`
|
||||||
|
|
||||||
|
Creates a user in the `nova-users` DynamoDB table. The password is
|
||||||
|
hashed with **Argon2id** (OWASP-minimum parameters: `time_cost=3,
|
||||||
|
memory_cost=65536 KiB, parallelism=1`) — the raw password is **never**
|
||||||
|
stored, logged, or put in any env var (INV-16).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth signup --email alice@example.com --password '...' --owner team-a
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens server-side (the `nova-idp-auth` Lambda):
|
||||||
|
1. Validates the payload (`email`, `password`, `owner`, `roles`).
|
||||||
|
2. Checks for a duplicate email → `409` if already registered.
|
||||||
|
3. `hash_password(password)` → Argon2id hash string.
|
||||||
|
4. `PutItem` into `nova-users` (`user_id`, `email`, `password_hash`,
|
||||||
|
`owner`, `roles`, `created_at`).
|
||||||
|
5. Emits `auth.sign_up` audit event (carries `user_id` + `email`,
|
||||||
|
never the password).
|
||||||
|
|
||||||
|
If the Argon2 C extension is unavailable, the Lambda returns **503**
|
||||||
|
(fail-closed — no weak hash, no pure-Python fallback; D-228).
|
||||||
|
|
||||||
|
## 3. `nova auth signin`
|
||||||
|
|
||||||
|
Verifies the password and returns a session token.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth signin --email alice@example.com --password '...'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Lambda:
|
||||||
|
1. Looks up the user by email (GSI `email-index` on `nova-users`).
|
||||||
|
2. `verify_password(password, stored_hash)` — Argon2id verify.
|
||||||
|
3. On mismatch or unknown email → `401 invalid_credentials` (the same
|
||||||
|
message for both, so an attacker can't enumerate emails by timing).
|
||||||
|
4. On success: `create_session(user_id)` writes a row to `nova-sessions`
|
||||||
|
(TTL 24h) and returns `session_id`.
|
||||||
|
|
||||||
|
## 4. `nova auth login`
|
||||||
|
|
||||||
|
Exchanges a PAT (or session) for a Nova OIDC token and stores it
|
||||||
|
locally.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth login --pat <PAT>
|
||||||
|
# or
|
||||||
|
nova auth login --session <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow:
|
||||||
|
1. The CLI calls the `nova-idp-token-vend` Lambda with the PAT.
|
||||||
|
2. The Lambda decodes the PAT's `jti`, does a **strongly-consistent**
|
||||||
|
`GetItem` on `nova-pats` (D-229 — revocation is reflected on the
|
||||||
|
next vend, within 60s P95).
|
||||||
|
3. Evaluates the ABAC policy (`platform/abac/token-vend.policy`) —
|
||||||
|
fail-closed (C-6.1). If the policy engine is unavailable or the
|
||||||
|
policy denies, the vend returns `403`.
|
||||||
|
4. Signs the OIDC token via KMS (`alias/nova-oidc-signing`,
|
||||||
|
`ECC_NIST_P256`, `ECDSA_SHA_256`) and returns it.
|
||||||
|
|
||||||
|
### The credentials file (`~/.nova/credentials.json`)
|
||||||
|
|
||||||
|
**C-7.3 (grill):** the file stores the OIDC token + PAT metadata
|
||||||
|
(`jti`, `exp`, `type`) **ONLY — NOT the raw PAT.** The raw PAT is
|
||||||
|
entered once at `nova auth login` and never persisted. This reduces the
|
||||||
|
filesystem-compromise blast radius: an attacker who reads
|
||||||
|
`credentials.json` gets a short-lived OIDC token (default 15 min), not
|
||||||
|
the long-lived PAT.
|
||||||
|
|
||||||
|
The file is `0600` (owner read/write only). Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active_credential_jti": "<jti>",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"jti": "<jti>",
|
||||||
|
"type": "nova_oidc_token",
|
||||||
|
"exp": 1787200000,
|
||||||
|
"token": "<oidc jwt>",
|
||||||
|
"stored_at": 1787199000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
"Most recent wins": `active_credential_jti` points at the
|
||||||
|
most-recently-stored credential. A subsequent `nova auth login`
|
||||||
|
replaces the entry with the same `jti` (or adds a new one).
|
||||||
|
|
||||||
|
## 5. `nova auth status`
|
||||||
|
|
||||||
|
Shows the active credential, the resolved mode, and the
|
||||||
|
`selection_reason`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "interactive",
|
||||||
|
"selection_reason": "credential:developer_pat",
|
||||||
|
"type": "nova_oidc_token",
|
||||||
|
"jti": "...",
|
||||||
|
"exp": 1787200000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no credential is stored: `{"status": "no active credential"}`.
|
||||||
|
|
||||||
|
## 6. `nova auth revoke --pat <jti>`
|
||||||
|
|
||||||
|
Revokes a PAT by `jti`. Marks the `nova-pats` row `status=revoked`
|
||||||
|
(the row is **retained** for audit, not deleted). The next
|
||||||
|
`nova auth login` with that PAT returns `403 pat_revoked` within 60s
|
||||||
|
P95 (D-229 strong read).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth revoke --pat <jti>
|
||||||
|
```
|
||||||
|
|
||||||
|
For emergency DDB-level revocation (when the CLI is unavailable), see
|
||||||
|
`docs/operator-guide-idp.md` §9.
|
||||||
|
|
||||||
|
## 7. Mode resolution (D-226)
|
||||||
|
|
||||||
|
The CLI resolves a client mode (`interactive` or `agent`) on every
|
||||||
|
invocation. The mode drives audit observability (INV-12) and some
|
||||||
|
behavioral defaults. The priority is **strict** — no silent fallbacks
|
||||||
|
(INV-13):
|
||||||
|
|
||||||
|
1. **`--mode` flag** (always wins): `nova apply --mode=agent`.
|
||||||
|
2. **`NOVA_CLIENT_MODE` env var**: `export NOVA_CLIENT_MODE=agent`.
|
||||||
|
Invalid values (anything other than `agent` / `interactive`) are
|
||||||
|
**warned and ignored** (fall through to the next level — not a
|
||||||
|
silent fallback, because a warning is emitted).
|
||||||
|
3. **Credential type** (from `~/.nova/credentials.json`): if the active
|
||||||
|
credential is `developer_pat` or `nova_oidc_token`, the mode is
|
||||||
|
`interactive` if a TTY is attached, `agent` otherwise (INV-14).
|
||||||
|
4. **TTY heuristic** (`sys.stdin.isatty()`): `interactive` if stdin is
|
||||||
|
a TTY, `agent` otherwise.
|
||||||
|
|
||||||
|
Every resolution returns a non-empty `selection_reason` (`flag`, `env`,
|
||||||
|
`credential:<type>`, or `tty`) so the audit event is self-explanatory.
|
||||||
|
|
||||||
|
### TTY vs piped stdout — the Edge 3 case
|
||||||
|
|
||||||
|
The TTY check is **`sys.stdin.isatty()`**, not `sys.stdout.isatty()`.
|
||||||
|
This matters when stdout is piped but stdin is still a terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova apply | tee log.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `stdout` is a pipe (to `tee`), but `stdin` is still the terminal.
|
||||||
|
So `sys.stdin.isatty()` returns `True` → **interactive mode**. This is
|
||||||
|
the common "I want to see the output AND save it" pattern, and it
|
||||||
|
correctly resolves to interactive because the human is driving.
|
||||||
|
|
||||||
|
The inverse — `echo '...' | nova apply` — has `stdin` piped, so
|
||||||
|
`sys.stdin.isatty()` is `False` → **agent mode** (no human at the
|
||||||
|
keyboard; the pipe is the driver).
|
||||||
|
|
||||||
|
### `developer_pat` + TTY → interactive; + no TTY → agent
|
||||||
|
|
||||||
|
A developer PAT (`type: developer_pat`) is a human credential. When a
|
||||||
|
TTY is attached, the CLI runs in `interactive` mode (prompts, human
|
||||||
|
confirmation). When no TTY is attached (piped stdin, CI, a scheduled
|
||||||
|
job), the same PAT runs in `agent` mode (no prompts, non-interactive).
|
||||||
|
This is INV-14: the credential type encodes the role, and the TTY
|
||||||
|
encodes the context.
|
||||||
|
|
||||||
|
A service-account PAT behaves the same way by type, but the max TTL is
|
||||||
|
much shorter (≤ 1h vs ≤ 24h for developer PATs — C-6.2) and CI systems
|
||||||
|
typically set `NOVA_CLIENT_MODE=agent` explicitly so the resolution is
|
||||||
|
deterministic regardless of the TTY state.
|
||||||
|
|
||||||
|
## 8. JWS-from-PAT key derivation (REQ-332, C-5.2)
|
||||||
|
|
||||||
|
`nova apply --local --sign-local-review` produces a JWS attestation — a
|
||||||
|
symmetric (HMAC-SHA256) signature over the attestation payload, keyed
|
||||||
|
by a key derived from the PAT.
|
||||||
|
|
||||||
|
### Why symmetric?
|
||||||
|
|
||||||
|
The grill (C-5.2) found that the original REQ-332 acceptance criterion
|
||||||
|
("public key derivable from the PAT") is unimplementable as an
|
||||||
|
asymmetric scheme — a PAT is a JWT, not a keypair. The fix: the PAT is
|
||||||
|
the **shared secret**. Both the signing key and the verification key
|
||||||
|
are derived from the PAT via the same KDF. The JWS uses `HS256`
|
||||||
|
(HMAC-SHA256), not `ES256`.
|
||||||
|
|
||||||
|
### The KDF
|
||||||
|
|
||||||
|
```
|
||||||
|
key = HKDF-SHA256(
|
||||||
|
input_key_material = PAT.encode('utf-8'),
|
||||||
|
salt = b'nova-local-attestation',
|
||||||
|
info = b'jws-signing-key',
|
||||||
|
length = 32,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(RFC 5869 / NIST SP 800-56C.) The `salt` and `info` are fixed
|
||||||
|
constants — they bind the derived key to the "nova-local-attestation /
|
||||||
|
jws-signing-key" purpose (key separation, INV-16). The same PAT always
|
||||||
|
yields the same key (deterministic); the key is never cached or
|
||||||
|
persisted (INV-15 — recomputed on each sign/verify call).
|
||||||
|
|
||||||
|
### Signing (`nova apply --local --sign-local-review`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `core.jws_attestation.sign_attestation(payload, pat)`:
|
||||||
|
- `derive_signing_key(pat)` → 32-byte key.
|
||||||
|
- `header = {"alg":"HS256","typ":"JWT"}`.
|
||||||
|
- `signing_input = b64url(header) + "." + b64url(payload)`.
|
||||||
|
- `signature = HMAC-SHA256(key, signing_input)`.
|
||||||
|
- Returns `b64url(header).b64url(payload).b64url(signature)` (the
|
||||||
|
compact JWS serialization).
|
||||||
|
2. The JWS is appended to the apply output.
|
||||||
|
|
||||||
|
### Verifying
|
||||||
|
|
||||||
|
Anyone holding the PAT can derive the same key and verify:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.jws_attestation import verify_attestation
|
||||||
|
payload = verify_attestation(jws_string, pat)
|
||||||
|
# raises JWSValidationError on tampering or wrong PAT
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify_attestation` recomputes the HMAC and compares in constant time
|
||||||
|
(`hmac.compare_digest`). Without the PAT, the HMAC cannot be forged —
|
||||||
|
this is the integrity guarantee for local-review attestations.
|
||||||
|
|
||||||
|
### What this is NOT
|
||||||
|
|
||||||
|
- **Not a non-repudiation scheme.** Anyone with the PAT can sign, so
|
||||||
|
the signature proves "someone with the PAT signed this payload" —
|
||||||
|
not a specific individual. Non-repudiation is the job of the audit
|
||||||
|
trail (INV-12), not the JWS.
|
||||||
|
- **Not a replacement for the OIDC token.** The OIDC token (from
|
||||||
|
`nova auth login`) is the credential for remote operations; the JWS
|
||||||
|
is for local-review attestation integrity only.
|
||||||
|
|
||||||
|
## 9. Service-account PATs (CI usage)
|
||||||
|
|
||||||
|
A CI system (GitHub Actions, or an internal forge runner) uses a service-account
|
||||||
|
PAT to run `nova apply` non-interactively.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# In CI:
|
||||||
|
export NOVA_PAT=<service-account-pat>
|
||||||
|
export NOVA_CLIENT_MODE=agent
|
||||||
|
nova auth login --pat "$NOVA_PAT"
|
||||||
|
nova apply --contract contracts/microservice.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- `NOVA_CLIENT_MODE=agent` makes mode resolution deterministic (level 2
|
||||||
|
beats level 3/4), regardless of whether the CI runner attaches a TTY.
|
||||||
|
- No TTY → `agent` mode anyway, but the env var is belt-and-suspenders.
|
||||||
|
- **Max TTL: ≤ 1h for service-account PATs** (C-6.2). The
|
||||||
|
`issue_pat(subject_type="service-account", ttl_seconds=3600)` call
|
||||||
|
clamps any higher request to 3600s. Rotate the PAT before it expires
|
||||||
|
(CI should mint a fresh one per run or daily).
|
||||||
|
|
||||||
|
### TTL summary (C-6.2)
|
||||||
|
|
||||||
|
| Subject type | Max TTL | Typical use |
|
||||||
|
|--------------|---------|-------------|
|
||||||
|
| `developer` | ≤ 24h (86400s) | local dev, interactive |
|
||||||
|
| `service-account` | ≤ 1h (3600s) | CI, automated pipelines |
|
||||||
|
|
||||||
|
The TTL is enforced in `core.pat_lifecycle.issue_pat` — a request for
|
||||||
|
more than the max is silently clamped (with an audit event recording
|
||||||
|
the requested vs actual TTL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — command reference
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| `nova auth signup` | create a user (Argon2id hash) |
|
||||||
|
| `nova auth signin` | verify password → session token |
|
||||||
|
| `nova auth login --pat <PAT>` | PAT → OIDC token, store in `~/.nova/credentials.json` (0600) |
|
||||||
|
| `nova auth status` | active credential + mode + selection_reason |
|
||||||
|
| `nova auth revoke --pat <jti>` | mark a PAT revoked (D-229 SLO ≤ 60s P95) |
|
||||||
|
| `nova apply --local --sign-local-review --pat <PAT>` | local apply + JWS attestation (HS256, PAT-derived key) |
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.nova/credentials.json` | OIDC token + PAT metadata (NOT raw PAT); 0600 |
|
||||||
|
| `~/.nova/contract.yml` | project contract (scaffolded by `nova init`) |
|
||||||
|
| `~/.nova/contract.yml.attestations/` | local attestation outputs |
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# kyverno-json (`kj`) Lambda layer
|
||||||
|
|
||||||
|
This document records how the `kj` (kyverno-json) binary is pinned and
|
||||||
|
bundled into the Nova token-vend Lambda layer (D-227, C-8.2).
|
||||||
|
|
||||||
|
## Pin (C-8.2)
|
||||||
|
|
||||||
|
The `kj` binary is pinned to a specific release. The version + SHA256
|
||||||
|
of the binary used for local ABAC tests and bundled into the Lambda
|
||||||
|
layer are recorded in [`platform/abac/kj-version.txt`](../platform/abac/kj-version.txt):
|
||||||
|
|
||||||
|
```
|
||||||
|
<version>
|
||||||
|
<sha256>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current pin:** `v0.0.3` —
|
||||||
|
`4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07`
|
||||||
|
(measured from `/usr/local/bin/kj` on the build host).
|
||||||
|
|
||||||
|
C-8.2 requires pinning to a specific release (not `latest`) and
|
||||||
|
recording the SHA256 so a supply-chain compromise of the upstream
|
||||||
|
release is detectable. The build step downloads the pinned release,
|
||||||
|
verifies the SHA256 against the recorded value, and aborts on mismatch.
|
||||||
|
|
||||||
|
## Lambda layer bundling
|
||||||
|
|
||||||
|
The publish workflow (P1, `.github/workflows/`) bundles the pinned `kj`
|
||||||
|
Linux amd64 binary into the `nova-cli` Lambda layer at `layer/bin/kj`.
|
||||||
|
At runtime the Lambda mounts the layer at `/opt`, so `kj` is on PATH at
|
||||||
|
`/opt/bin/kj`. `KyvernoJsonEngine.is_configured()` checks `which kj` →
|
||||||
|
`/opt/bin/kj` and returns `False` when absent — the token-vend Lambda
|
||||||
|
then **fails closed** (C-6.1, 403 `abac_eval_failed`), it never vends a
|
||||||
|
token without an ABAC decision.
|
||||||
|
|
||||||
|
## Local testing
|
||||||
|
|
||||||
|
`/usr/local/bin/kj` exists on the build host. The local ABAC tests
|
||||||
|
(`tests/test_abac_policy.py`, `tests/test_abac_fail_closed.py`) use the
|
||||||
|
real `kj` binary — they are skipped (not failed) when `kj` is absent.
|
||||||
|
|
||||||
|
## Fallback / migration path (D-227)
|
||||||
|
|
||||||
|
If the `kj` Go binary proves unsuitable for the Lambda runtime (e.g. a
|
||||||
|
future release exceeds the 250 MB layer unzip limit or drops AL2023
|
||||||
|
compatibility), the migration path is to run kyverno-json on AWS
|
||||||
|
Fargate behind an internal NLB and have the token-vend Lambda call it
|
||||||
|
over HTTP. The `PolicyEngine` Protocol (`core/policy_engine.py`) is the
|
||||||
|
swap boundary — a `KyvernoJsonHttpEngine` would implement the same
|
||||||
|
protocol without touching the token-vend Lambda's ABAC fail-closed
|
||||||
|
logic. This is a documented fallback, not the v1.28 default.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# KMS asymmetric key provisioning (C-1.1)
|
||||||
|
|
||||||
|
This document records the C-1.1 verification for the Nova OIDC signing
|
||||||
|
KMS key and the provisioning path used by `nova idp setup`.
|
||||||
|
|
||||||
|
## C-1.1 verification (P4)
|
||||||
|
|
||||||
|
C-1.1 requires verifying KMS asymmetric key support **before**
|
||||||
|
implementation. The verification command is:
|
||||||
|
|
||||||
|
```
|
||||||
|
aws kms create-key \
|
||||||
|
--key-spec ECC_NIST_P256 \
|
||||||
|
--key-usage SIGN_VERIFY \
|
||||||
|
--description nova-oidc-signing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result on the P4 build host:** AWS credentials are not available
|
||||||
|
(`Unable to locate credentials`), so the live verification could not
|
||||||
|
run. This is recorded as a **P4 CI gate**: the `nova idp setup --check`
|
||||||
|
command (Wave 8) performs this verification when AWS creds are present
|
||||||
|
and reports it as a missing prerequisite when they are not. The code
|
||||||
|
proceeds against the documented KMS API (REQ-337); tests use a test
|
||||||
|
ECDSA P-256 keypair + mocked `boto3.client("kms")` (no real AWS calls).
|
||||||
|
|
||||||
|
KMS asymmetric signing keys (`ECC_NIST_P256` + `SIGN_VERIFY`) are GA
|
||||||
|
in all commercial regions (announced 2020-11). The
|
||||||
|
`ECDSA_SHA_256` signing algorithm is supported. Confidence: high.
|
||||||
|
|
||||||
|
## Key spec (REQ-337)
|
||||||
|
|
||||||
|
* **Key spec:** `ECC_NIST_P256` (NIST P-256 / secp256r1)
|
||||||
|
* **Key usage:** `SIGN_VERIFY`
|
||||||
|
* **Signing algorithm:** `ECDSA_SHA_256` (JWS `ES256`)
|
||||||
|
* **Alias:** `alias/nova-oidc-signing`
|
||||||
|
* **Rotation:** manual, 90 days (matches D-069 CMK cadence). New key +
|
||||||
|
re-point alias + JWKS serves both `kid`s during overlap.
|
||||||
|
|
||||||
|
## DER → raw ECDSA conversion (the #1 gotcha)
|
||||||
|
|
||||||
|
KMS `sign()` returns a **DER-encoded** ASN.1 ECDSA signature. JWS
|
||||||
|
(RFC 7515 §3.1.3) requires the **raw** `r‖s` concatenation, each
|
||||||
|
coordinate 32 bytes big-endian. The conversion (in
|
||||||
|
`core/kms_signing.py:der_to_raw_ecdsa`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||||
|
r, s = decode_dss_signature(der_sig)
|
||||||
|
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||||
|
```
|
||||||
|
|
||||||
|
This is verified by `tests/test_kms_signing.py` and the CAP-037
|
||||||
|
round-trip test (`tests/test_kms_roundtrip.py`).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user