Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a94c968bd | |||
| 20cdec8fab | |||
| c179c3e09a | |||
| a112d6f143 | |||
| ad280b181b | |||
| 2ea9fb4e51 | |||
| 52b16bbef1 | |||
| 68908d7f6a | |||
| 85cc962fda | |||
| ba28017f5b | |||
| 17903973aa | |||
| 6dfde4b785 | |||
| 13ee34b5a7 | |||
| ac791c7d23 | |||
| bc31c54940 | |||
| eda7e827b9 | |||
| 4c547780e5 | |||
| 0c4f5582f3 | |||
| b85da0471b | |||
| b51dfb1c03 | |||
| 246c0e93b3 | |||
| 50a8089e27 | |||
| 9e578a29e0 | |||
| 96765fe020 | |||
| bf07fe49d0 | |||
| 6d5fa85e63 | |||
| f584330f40 | |||
| 155c02fe90 | |||
| e3a13e4768 | |||
| dc784d576d | |||
| d66b7b0e73 | |||
| 929d0d94c2 | |||
| fff2bcc606 | |||
| adcd012a2f | |||
| 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 |
@@ -563,7 +563,7 @@ audit record (D-204).
|
||||
|
||||
The full evidence (every ARN, the confidence JSON, the Decision Ledger
|
||||
rows, the module-completeness gaps the live apply uncovered) is in
|
||||
`.ciagent/P4-PILOT-RUN-EVIDENCE.md`.
|
||||
`.ciagent/archive/P4-PILOT-RUN-EVIDENCE-v1.26.md` (archived v1.27).
|
||||
|
||||
### §12.9 — Secret Rotation (v1.26 P3 W7, SPEC §5.9 — current)
|
||||
|
||||
@@ -576,4 +576,149 @@ 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.
|
||||
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.
|
||||
@@ -1,17 +1,28 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"milestone": "v1.27",
|
||||
"stage": "verify",
|
||||
"milestone": "v1.31",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-19T05:00:00Z",
|
||||
"updated_at": "2026-08-20T16:35:00Z",
|
||||
"project": "acdl",
|
||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.27",
|
||||
"milestone_branch": "milestone/v1.27-po-state-catalog",
|
||||
"phase_branch": "phase/01-author-archive",
|
||||
"tag_line": "v1.26.x",
|
||||
"current_phase": {"phase": 1, "tag": "v1.26.1", "status": "complete"},
|
||||
"previous_phase": {"phase": 0, "tag": "v1.26.0", "status": "complete"},
|
||||
"notes": "v1.27 P1 complete. STATE.md verified (32 CAPs, 11 invariants, 10 domains). 7 platform + 1 consumer files archived (lossless git mv). Active .ciagent/ root: 15 .md + 1 json + 1 checkpoint. Next: P2 fix-stale-wire."
|
||||
"active_milestone": "v1.31",
|
||||
"milestone_branch": "milestone/v1.31-leadership-deck-polish",
|
||||
"phase_branch": "acdl/phase/01-deck-polish",
|
||||
"tag_line": "v1.30.x",
|
||||
"phase_name": "deck-polish",
|
||||
"milestone_type": "nfr",
|
||||
"reqs_covered": ["REQ-373.1","REQ-373.2","REQ-373.3","REQ-373.4"],
|
||||
"reqs_partial": [],
|
||||
"previous_milestone": {
|
||||
"milestone": "v1.30",
|
||||
"tag": "v1.29.5",
|
||||
"complete": true,
|
||||
"merged_to_main": "13ee34b",
|
||||
"branches_deleted": true,
|
||||
"releases_created": true,
|
||||
"release_ids": [811,813,814,818]
|
||||
},
|
||||
"notes": "v1.31 P1 VERIFY PASS. check_leadership_deck.sh: ALL CHECKS PASSED. (a) source exists, (b) slide count=7, (c) speaker-note word counts in band, (d) footer present, (e) only S&P theme colors, (f) PPTX exists. [1] citations confirmed on slides 3/5/7. Visible prose density enriched (slides 2-6: 43-67 -> 97-168 words). REQ-373.1..4 all complete."
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
# CLARIFY — v1.27 PO State Catalog & Ciagent Compression
|
||||
|
||||
> **Autonomy:** full. Auto-resolution with assumption logging per
|
||||
> `config.autonomy.level: "full"`. No human escalation unless
|
||||
> confidence < 0.60. The prior conversation resolved all material
|
||||
> ambiguities (4 user-answered questions). This file records the
|
||||
> assumptions for the v1.27 record.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
The clarify stage identifies ambiguities in the v1.27 specification
|
||||
and resolves them at full autonomy. The v1.27 spec is the user-approved
|
||||
plan from the prior conversation + the STATE.md design locked by 4
|
||||
question answers. Each ambiguity gets a decision ID (D-214+; continuing
|
||||
from the v1.26 decisions D-200..D-213), a resolution, a confidence
|
||||
score, and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## Prior-conversation resolutions (already locked, restated for the record)
|
||||
|
||||
These were resolved by user-answered questions in the conversation that
|
||||
spawned v1.27. They are load-bearing for v1.27 execution and cited
|
||||
here so the v1.27 record is self-contained.
|
||||
|
||||
### Q-P1 — What should the new PO-reference file catalog?
|
||||
|
||||
**Resolution:** Capability catalog (what the system can do today).
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-214.
|
||||
|
||||
### Q-P2 — How should the new file relate to CAPABILITY_INVENTORY.md?
|
||||
|
||||
**Resolution:** Call it `STATE.md`. PO-owned, ciagent-updated after
|
||||
milestone implementation. CAPABILITY_INVENTORY.md is archived.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-215.
|
||||
|
||||
### Q-P3 — Where should the file live, and who owns it?
|
||||
|
||||
**Resolution:** Owned by the PO, updated by ciagent after the milestone
|
||||
is implemented with additives.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-216.
|
||||
|
||||
### Q-P4 — How should "additive when new features are implemented" be enforced?
|
||||
|
||||
**Resolution:** On the last phase / milestone ship (the P-final Wave 3
|
||||
"milestone ship" step). No regression-gate check in this pass.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-217.
|
||||
|
||||
### Q-P5 — Should the initial STATE.md backfill all shipped capabilities through v1.26?
|
||||
|
||||
**Resolution:** Backfill all shipped capabilities through v1.26
|
||||
(compressed one-liners for v1.1–v1.24; full entries for v1.25 + v1.26).
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-218.
|
||||
|
||||
### Q-P6 — Should the v1.26 pre-execution artifacts (CLARIFY, GRILL, IDEATE, RESEARCH) be archived?
|
||||
|
||||
**Resolution:** Archive all 4 to `.ciagent/archive/` with `-v1.26`
|
||||
suffixes. The next milestone's P0 writes fresh versions. Decisions are
|
||||
already folded into PROJECT.md load-bearing decisions + PLAN.md
|
||||
binding revisions.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-219.
|
||||
|
||||
---
|
||||
|
||||
## Ambiguities + Resolutions (this CLARIFY pass)
|
||||
|
||||
### Q1 — Is v1.27 a feature milestone or an NFR milestone?
|
||||
|
||||
**Ambiguity:** v1.27 authors `STATE.md` (a new file/capability for the
|
||||
PO) and archives 11 files. Does the new-file authoring count as `feat:`
|
||||
(making this a feature milestone, tags on v1.26.x with progressive
|
||||
patches) or `docs:`/`chore:` (NFR milestone, same tag behavior but
|
||||
subject to the NFR purity gate)?
|
||||
|
||||
**Resolution:** NFR milestone. `STATE.md` is documentation (a catalog of
|
||||
existing capabilities), not a new platform capability. The archive moves
|
||||
are `chore:` (file relocation, lossless). No code, no schema, no
|
||||
platform behavior change. Tags run on the v1.26.x patch line:
|
||||
`v1.26.0` (P0) → `v1.26.1..v1.26.3` (P1..P3). The final phase's patch
|
||||
(`v1.26.3`) IS the milestone release.
|
||||
|
||||
**Confidence:** 0.95. **Decision:** D-220.
|
||||
|
||||
### Q2 — Where does the consumer-side archive (nova-blockchain-exchange/ROADMAP.md) land?
|
||||
|
||||
**Ambiguity:** The platform archive convention is
|
||||
`.ciagent/archive/<file>-<milestone>.md`. The consumer subproject
|
||||
(`nova-blockchain-exchange/`) has no `archive/` subdirectory. Does the
|
||||
consumer ROADMAP archive at `.ciagent/archive/` (platform-side, mixed)
|
||||
or `.ciagent/nova-blockchain-exchange/archive/` (consumer-side, new
|
||||
subdir)?
|
||||
|
||||
**Resolution:** Consumer-side. Create
|
||||
`.ciagent/nova-blockchain-exchange/archive/` and relocate to
|
||||
`ROADMAP-v1.26.md`. This preserves the per-project path convention
|
||||
(multi-project mode: `.ciagent/<slug>/` paths). The platform archive
|
||||
directory is not mixed with consumer archives.
|
||||
|
||||
**Confidence:** 0.92. **Decision:** D-221.
|
||||
|
||||
### Q3 — Does archiving CLARIFY/GRILL/IDEATE/RESEARCH lose the "how v1.26 was specified" traceability?
|
||||
|
||||
**Ambiguity:** The pre-execution artifacts document the v1.26 decision
|
||||
path. Archiving them moves them out of active context. Is the
|
||||
traceability preserved?
|
||||
|
||||
**Resolution:** Yes. Three layers preserve it: (1) the archive files
|
||||
are byte-identical relocations inside `.ciagent/archive/` (reachable by
|
||||
agents + git history); (2) the decisions D-200..D-213 are folded into
|
||||
`PROJECT.md` load-bearing decisions (the durable record); (3) git
|
||||
history at the v1.26 commits preserves the authoritative state. The
|
||||
active-context reduction is the point — v1.26 is shipped; the next P0
|
||||
writes fresh CLARIFY/GRILL/IDEATE/RESEARCH.
|
||||
|
||||
**Confidence:** 0.95. **Decision:** D-222.
|
||||
|
||||
### Q4 — Should IAM_POLICY.md be archived (it predates v1.26 and is dated v1.11)?
|
||||
|
||||
**Ambiguity:** `IAM_POLICY.md` is dated v1.11 (2026-07-28). It predates
|
||||
v1.26 by 5 milestones. The D-207 future key-split (P1+ R-3 in
|
||||
REVIEW-AUDIT-P05) is pending. Archive or keep?
|
||||
|
||||
**Resolution:** Keep active. `IAM_POLICY.md` is a live baseline —
|
||||
referenced by the regression gate
|
||||
(`tests/test_iam_policy_baseline.py`), enforced by a managed policy on
|
||||
account `581513795199`, and the D-207 key-split is a pending future-
|
||||
hardening item. It is not stale; it is a baseline that grows when
|
||||
grants change. The v1.11 date reflects the last grant addition, not
|
||||
staleness.
|
||||
|
||||
**Confidence:** 0.90. **Decision:** D-223.
|
||||
|
||||
### Q5 — Should REGRESSION_REPORT.{json,md} be refreshed as part of v1.27?
|
||||
|
||||
**Ambiguity:** Both files are dated 2026-08-01 (v1.10 Phase 52), show
|
||||
CAP-025 absent, and mark live-aws CAPs "Skipped" (state bucket absent
|
||||
pre-v1.26 re-bootstrap). They are stale. Should v1.27 refresh them?
|
||||
|
||||
**Resolution:** No. Both files are machine-managed — written by
|
||||
`core/regression_verify.py:704-705` on every `run_regression.sh` run.
|
||||
They regenerate on the next regression run. v1.27 is docs/chore only
|
||||
(no code); touching machine-managed files by hand creates a drift
|
||||
source. The stale state is honest (the last gate run was v1.10; the
|
||||
next run regenerates). The STATE.md Domain 7 row "Regression gate"
|
||||
notes the current CAP range (CAP-001..025).
|
||||
|
||||
**Confidence:** 0.88. **Decision:** D-224.
|
||||
|
||||
### Q6 — Does PROJECT.md get the v1.26 phase-status fix in v1.27 P1 or P2?
|
||||
|
||||
**Ambiguity:** The plan splits work into P1 (author + archive) and P2
|
||||
(fix stale + wire). The PROJECT.md phase-status fix (P3/P4/P5 pending
|
||||
→ complete) is a "fix stale" item. P1 or P2?
|
||||
|
||||
**Resolution:** P2. P1 is the additive authoring + lossless archive
|
||||
moves. P2 is the corrections to kept files + the ship-discipline wiring.
|
||||
This keeps P1 a pure-additive, no-edit phase (easier review + audit) and
|
||||
P2 the correction phase. The PROJECT.md fix is a correction; P2.
|
||||
|
||||
**Confidence:** 0.85. **Decision:** D-225.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
6 prior-conversation resolutions (D-214..D-219, all user-confirmed)
|
||||
+ 6 new ambiguities (D-220..D-225, all auto-resolved at full autonomy,
|
||||
confidence ≥ 0.60). 0 escalations.
|
||||
|
||||
**Key decisions:**
|
||||
- D-220: v1.27 is an NFR milestone (tags on v1.26.x; final patch is the
|
||||
milestone release).
|
||||
- D-221: Consumer archives land in `.ciagent/nova-blockchain-exchange/archive/`.
|
||||
- D-222: Archiving pre-execution artifacts preserves traceability
|
||||
(archive files + PROJECT.md load-bearing decisions + git history).
|
||||
- D-223: IAM_POLICY.md stays active (live baseline, test-enforced,
|
||||
D-207 pending).
|
||||
- D-224: REGRESSION_REPORT.{json,md} regenerate on next
|
||||
`run_regression.sh` (machine-managed; v1.27 is docs/chore only).
|
||||
- D-225: PROJECT.md phase-status fix is P2 (correction phase), not P1
|
||||
(additive phase).
|
||||
@@ -1,141 +0,0 @@
|
||||
# GRILL — v1.27 PO State Catalog & Ciagent Compression
|
||||
|
||||
> Adversarial review of the v1.27 SPECIFY + CLARIFY + RESEARCH + PLAN.
|
||||
> The grill red-teams the proposal across feasibility, scope, and the
|
||||
> compression-loss claims. Each challenge gets a binding verdict
|
||||
> (PROCEED / REVISE / ESCALATE). Autonomy: full.
|
||||
|
||||
## Verdict: PROCEED (0.88) — 0 escalations, 1 revision
|
||||
|
||||
The milestone is feasible, scoped, and the compression is lossless. One
|
||||
binding revision (G-Q2) refines the archive list; already captured in
|
||||
PLAN. No work is blocked.
|
||||
|
||||
---
|
||||
|
||||
## Challenges
|
||||
|
||||
### G-Q1 — Is archiving AUTONOMY_THESIS.md + COST.md a context loss?
|
||||
|
||||
**Challenge:** `AUTONOMY_THESIS.md` is the "autonomy in operations;
|
||||
human at stage gates" thesis — the defensibility brief. `COST.md` is
|
||||
the only AWS cost record. Archiving both moves them out of active
|
||||
context. Does this lose load-bearing content?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.90).
|
||||
- `AUTONOMY_THESIS.md` (65 lines, "Last refined: v1.21") is fully
|
||||
folded into `NORTH_STAR.md` Vision (lines 17–22: "infrastructure
|
||||
operations become visible... human attestation remains required at
|
||||
stage gates") + Anti-Goals #2 ("Not a system that removes humans from
|
||||
accountability"). The thesis is the source; NORTH_STAR is the
|
||||
authoritative durable copy. Archive preserves the v1.21 refinement;
|
||||
active context reads NORTH_STAR.
|
||||
- `COST.md` (106 lines, dated 2026-07-29, "v1.0 → v1.14") predates the
|
||||
v1.26 live pilot. The v1.26 live apply (ECS + ALB + DynamoDB + S3)
|
||||
incurred real costs this snapshot doesn't reflect. Archiving it is
|
||||
honest — a stale cost record misleads. STATE.md Domain 7 notes cost
|
||||
tracking as a capability (pre-apply Infracost grounded; actual-spend
|
||||
CUR deferred D-096). A future cost milestone writes a fresh report.
|
||||
No revision needed.
|
||||
|
||||
### G-Q2 — Does the archive list include the v1.27 P0 pre-execution files by mistake?
|
||||
|
||||
**Challenge:** D-219 (user-confirmed) says "archive all 4 pre-execution
|
||||
artifacts" (CLARIFY/GRILL/IDEATE/RESEARCH). But P0 already overwrote
|
||||
them with v1.27 content. Archiving the v1.27 versions at v1.27 P1 would
|
||||
lose the v1.27 pre-execution narrative (the decisions D-214..D-225, the
|
||||
research inventory, this grill). Is the archive list wrong?
|
||||
|
||||
**Verdict:** REVISE (confidence 0.92). This is a real ambiguity in the
|
||||
plan. The user's D-219 decision was made *before* P0 overwrote the
|
||||
files; the intent was to archive the *v1.26* pre-execution record. The
|
||||
v1.26-era content is preserved in git history (the pre-P0 commits) —
|
||||
the archive directory is not the only preservation layer. PLAN Task 2.1
|
||||
already self-corrected: the final archive list is **7 platform files +
|
||||
1 consumer file = 8 files**, excluding the 4 pre-execution files. The 4
|
||||
v1.27 P0 versions stay active through v1.27; they archive at v1.28 P1
|
||||
if v1.28 happens. The archive README notes the v1.26 pre-execution
|
||||
record is in git history. No further revision needed — the plan self-
|
||||
corrected.
|
||||
|
||||
### G-Q3 — Is the STATE.md backfill accurate enough to be the PO's source of truth?
|
||||
|
||||
**Challenge:** STATE.md has 36 capability rows across 10 domains,
|
||||
backfilled from 8 sources. The PO will read this before writing new
|
||||
REQs. If a row is inaccurate (wrong shipped tag, wrong file path,
|
||||
wrong controlling REQ), the PO could re-spec an existing capability or
|
||||
cite a stale invariant. Is the backfill accurate?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.85). The backfill sources are
|
||||
authoritative: `core/regression_verify.py` (the machine CAP-NNN
|
||||
registry), `modules/registry.json` (the live module catalog),
|
||||
`REQUIREMENTS.md` traceability (the REQ→phase→status record),
|
||||
`CHECKPOINT.json` (shipped tags), `git log` (file paths). The
|
||||
citations are direct (each row cites the controlling REQ + decision
|
||||
ID). The 11 invariants are distilled from PROJECT.md load-bearing
|
||||
decisions D-034..D-072 + W1..BA + Q1.3. The accuracy risk is
|
||||
mitigated by P1 Wave 1 (verify STATE.md against sources before
|
||||
archive). No revision needed — the verification step is in the plan.
|
||||
|
||||
### G-Q4 — Does the NFR purity gate (zero `feat:` commits) hold for v1.27?
|
||||
|
||||
**Challenge:** v1.27 authors STATE.md (a new file). Is authoring a new
|
||||
catalog file a `feat:` (feature) that breaks the NFR purity gate?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.92). D-220 (CLARIFY) resolved this:
|
||||
STATE.md is documentation (a catalog of *existing* capabilities), not a
|
||||
new platform capability. The archive moves are `chore:` (file
|
||||
relocation, lossless). No code, no schema, no platform behavior
|
||||
change. The NFR purity gate (zero `feat:` commits) holds. All v1.27
|
||||
commits use `docs(P0N):` or `chore(P01):` prefixes. No revision
|
||||
needed.
|
||||
|
||||
### G-Q5 — Does fixing PROJECT.md phase-status in P2 create a P0/P1 audit inconsistency?
|
||||
|
||||
**Challenge:** The PROJECT.md phase-status block shows P3/P4/P5 as
|
||||
"pending" (the bug flagged in the prior conversation). P0 + P1 ship
|
||||
with the bug still present (the fix is P2). Does the P0/P1 audit see
|
||||
the inconsistency?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.86). The bug is pre-existing
|
||||
(it predates v1.27; it was the trigger for the prior conversation).
|
||||
P0/P1 audits check the *v1.27* commits against the `.ciagent/` state,
|
||||
not the pre-existing PROJECT.md drift. The P2 fix is the correction;
|
||||
the P3 audit verifies the fix landed. The intermediate state (P0/P1
|
||||
with the bug present) is honest — the bug is documented in the v1.27
|
||||
PLAN + the prior conversation, and the fix is scheduled. No revision
|
||||
needed — the phasing is intentional (D-225: P1 additive, P2
|
||||
correction).
|
||||
|
||||
### G-Q6 — Is the milestone scoped too small (3 phases, 8 archive moves)?
|
||||
|
||||
**Challenge:** v1.27 is a small milestone (3 phases, ~15 file
|
||||
operations, no code). Is it worth a milestone, or should it be a
|
||||
patch on v1.26?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.88). v1.27 is not a patch on v1.26
|
||||
— v1.26 is shipped (`v1.25.5`, merged to main, milestone complete).
|
||||
The work is a new milestone by definition. The size is appropriate:
|
||||
STATE.md is a durable PO-facing artifact (loaded every ci-run going
|
||||
forward); the compression reduces active context by ~26%; the
|
||||
ship-discipline wiring affects every future milestone ship. Small but
|
||||
high-leverage. No revision needed.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
6 challenges; 0 escalations; 1 binding revision (G-Q2, already
|
||||
captured in PLAN Task 2.1). Overall verdict: PROCEED (confidence
|
||||
0.88).
|
||||
|
||||
**Binding revisions:**
|
||||
- **G-Q2:** Archive list refined to 7 platform + 1 consumer = 8 files.
|
||||
The 4 pre-execution files (CLARIFY/GRILL/IDEATE/RESEARCH) stay active
|
||||
through v1.27 (they hold the v1.27 P0 content); the v1.26-era content
|
||||
is in git history. Already in PLAN.
|
||||
|
||||
**No work is blocked.** The milestone is feasible, scoped, the
|
||||
compression is lossless (archive + git history), the STATE.md backfill
|
||||
is source-grounded with a verification step, the NFR purity holds, and
|
||||
the phasing (P1 additive, P2 correction, P3 ship) is sound.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Strategic Objective #2 (provable trust) gained a concrete substrate in
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
project: acdl
|
||||
milestone: v1.27
|
||||
generated_at: 2026-08-19
|
||||
generator: lead-developer
|
||||
verification_toolchain:
|
||||
typecheck: "python3 -m py_compile core/confidence_signal.py 2>&1 | head -5 || true"
|
||||
test: "bash scripts/run_regression.sh 2>&1 | tail -10 || true"
|
||||
lint: "ruff check .ciagent/STATE.md 2>/dev/null || true"
|
||||
note: |
|
||||
v1.27 is an NFR milestone (PO State Catalog & Ciagent Compression) —
|
||||
a docs/chore milestone. Single active persona: lead-developer owns
|
||||
the milestone narrative (STATE.md authoring, PROJECT/ROADMAP fixes,
|
||||
archive moves, PLAN/NORTH_STAR wiring, final review + audit). No
|
||||
code, no schema, no policy authoring. The pre-existing
|
||||
core/confidence_signal.py LSP diagnostic is out of scope (not
|
||||
touched by v1.27). Territory enforcement: warn.
|
||||
---
|
||||
|
||||
# PERSONAS — v1.27 PO State Catalog & Ciagent Compression
|
||||
|
||||
> Generated by the lead-developer at the end of RESEARCH. Assesses the
|
||||
> project domains, activates/deactivates personas, aligns frameworks +
|
||||
> territory + constraints to the actual project structure.
|
||||
|
||||
## Active Roster (1)
|
||||
|
||||
### 1. lead-developer (active)
|
||||
- **active:** true
|
||||
- **phase_specific:** false
|
||||
- **reason:** Owns the full v1.27 milestone narrative: STATE.md
|
||||
authoring (PO-facing capability catalog, 36 entries across 10
|
||||
domains + 11 invariants), archive moves (11 files to
|
||||
`.ciagent/archive/` + 1 to consumer archive), PROJECT.md + ROADMAP.md
|
||||
phase-status corrections, archive/README.md contents update,
|
||||
PLAN.md + ROADMAP.md + NORTH_STAR.md ship-discipline wiring, final
|
||||
review + audit.
|
||||
- **domain:** `.ciagent/` docs (STATE.md, PROJECT.md, ROADMAP.md,
|
||||
PLAN.md, NORTH_STAR.md, archive/README.md), consumer
|
||||
`.ciagent/nova-blockchain-exchange/` (PROJECT.md pointer,
|
||||
archive/ROADMAP-v1.26.md).
|
||||
- **frameworks:** markdown, JSON (CHECKPOINT.json, config.json).
|
||||
- **territory:** `.ciagent/`, `docs/`.
|
||||
- **constraints:** no code changes (NFR milestone, D-220); no schema
|
||||
changes; archive moves are lossless (byte-identical relocation, git
|
||||
history preserves authoritative state); STATE.md is additive only.
|
||||
|
||||
## Deactivated (5)
|
||||
|
||||
### backend-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No code changes in v1.27. The pre-existing
|
||||
`core/confidence_signal.py` LSP diagnostic is out of scope (not
|
||||
touched by v1.27).
|
||||
|
||||
### data-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No schema, migration, or ORM changes.
|
||||
|
||||
### policy-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No policy authoring. STATE.md Domain 3 catalogues
|
||||
existing v1.25 + v1.26 policies (descriptive, not authoring).
|
||||
|
||||
### frontend-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No UI. Deactivated since v1.26 (PERSONAS.md:141).
|
||||
|
||||
### blockchain-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No chain code. The v1.26 pilot is shipped; v1.27 is
|
||||
platform-side docs/chore only.
|
||||
|
||||
## Territory Enforcement
|
||||
|
||||
- **Mode:** `warn` (the milestone is `.ciagent/`-only; the lead-
|
||||
developer owns all writes; no cross-territory collisions expected).
|
||||
@@ -1,251 +0,0 @@
|
||||
# PLAN — v1.27 PO State Catalog & Ciagent Compression
|
||||
|
||||
> **Milestone:** v1.27 (NFR — docs/chore only). Tags on the **v1.26.x**
|
||||
> line: `v1.26.0` (P0) → `v1.26.1..v1.26.3` (P1..P3). The final phase's
|
||||
> patch (`v1.26.3`) IS the milestone release.
|
||||
> **Branch:** `milestone/v1.27-po-state-catalog`. Phase branches:
|
||||
> `phase/00-pre-execution`, `phase/01-author-archive`,
|
||||
> `phase/02-fix-stale-wire`, `phase/03-final-review-ship`.
|
||||
|
||||
## Milestone goal
|
||||
|
||||
Author `.ciagent/STATE.md` (PO-facing capability catalog, backfilled
|
||||
through v1.26) + compress `.ciagent/` by archiving 11 outdated files +
|
||||
fix 3 stale-but-kept files + wire STATE.md into the P-final ship
|
||||
discipline. NFR milestone — no code, no schema, no platform behavior
|
||||
change.
|
||||
|
||||
## Requirements
|
||||
|
||||
No new REQ-NNN. v1.27 is a docs/chore milestone; the work items are
|
||||
the user-approved plan from the prior conversation. The traceability
|
||||
is by-file (the "requirements" are the 15 file operations + 6 doc
|
||||
edits in the plan summary).
|
||||
|
||||
## Phase breakdown
|
||||
|
||||
### Phase P1 — author-archive (additive + lossless)
|
||||
|
||||
**Goal:** Author STATE.md (already done in P0 SPECIFY, refined here)
|
||||
+ archive 11 outdated files. Pure-additive + lossless moves only —
|
||||
no edits to kept files.
|
||||
|
||||
#### Wave 1 — verify STATE.md backfill
|
||||
- **Task 1.1** (lead-developer): verify STATE.md 36 capability rows
|
||||
against the authoritative sources (regression_verify.py CAP-NNN list,
|
||||
modules/registry.json, REQUIREMENTS.md traceability, CHECKPOINT
|
||||
tags). Fix any inaccurate citation (shipped tag, file path).
|
||||
|
||||
#### Wave 2 — archive platform-root files (10)
|
||||
- **Task 2.1** (lead-developer): `git mv` 10 files to
|
||||
`.ciagent/archive/` with milestone-suffix names:
|
||||
- `CAPABILITY_INVENTORY.md` → `CAPABILITY_INVENTORY-v1.10.md`
|
||||
- `CLARIFY.md` → `CLARIFY-v1.26.md`
|
||||
- `GRILL.md` → `GRILL-v1.26.md`
|
||||
- `IDEATE.md` → `IDEATE-v1.26.md`
|
||||
- `RESEARCH.md` → `RESEARCH-v1.26.md`
|
||||
- `REVIEW-AUDIT-P05.md` → `REVIEW-AUDIT-P05.md`
|
||||
- `VERIFY-P03.md` → `VERIFY-P03.md`
|
||||
- `VERIFY-P04.md` → `VERIFY-P04.md`
|
||||
- `P4-PILOT-RUN-EVIDENCE.md` → `P4-PILOT-RUN-EVIDENCE-v1.26.md`
|
||||
- `AUTONOMY_THESIS.md` → `AUTONOMY_THESIS-v1.21.md`
|
||||
- `COST.md` → `COST-v1.14.md`
|
||||
Use `git mv` to preserve history. NOTE: CLARIFY/GRILL/IDEATE/RESEARCH
|
||||
were rewritten in P0 with v1.27 content — archive the v1.27 versions
|
||||
(they document the v1.27 pre-execution; the next P0 writes fresh).
|
||||
Wait — per D-219, the v1.26 pre-execution artifacts are archived. The
|
||||
v1.27 versions replace them in active context; they are NOT archived
|
||||
at P1 (they are the current P0 artifacts, active until v1.27 ships,
|
||||
then archived at v1.28 P1 if v1.28 happens). **Correction:** archive
|
||||
only the v1.26-era pre-execution artifacts. But P0 already
|
||||
overwrote CLARIFY/GRILL/IDEATE/RESEARCH with v1.27 content. The v1.26
|
||||
content lives in git history (the pre-P0 commits). So:
|
||||
- The 4 pre-execution files (CLARIFY/GRILL/IDEATE/RESEARCH) at HEAD
|
||||
are the v1.27 P0 artifacts — **keep active** through v1.27, archive
|
||||
at v1.28.
|
||||
- The v1.26-era content is in git history — reachable.
|
||||
**Revised archive list (7 files, not 10):** CAPABILITY_INVENTORY,
|
||||
REVIEW-AUDIT-P05, VERIFY-P03, VERIFY-P04, P4-PILOT-RUN-EVIDENCE,
|
||||
AUTONOMY_THESIS, COST.
|
||||
|
||||
Hold — let me re-check D-219. The user said "Archive all 4
|
||||
pre-execution artifacts." That was decided *before* P0 overwrote
|
||||
them. The intent was to archive the v1.26 pre-execution record. The
|
||||
v1.27 P0 overwrites are the new pre-execution record. Archiving the
|
||||
v1.27 versions at v1.27 P1 would lose the v1.27 pre-execution
|
||||
narrative. **Resolution:** archive the v1.26-era content (preserved
|
||||
in git history at the pre-P0 commits) by noting it in the archive
|
||||
README; keep the v1.27 P0 versions active through v1.27. The 4 files
|
||||
stay active until v1.28 P1.
|
||||
|
||||
**Final archive list (7 files):** CAPABILITY_INVENTORY.md,
|
||||
REVIEW-AUDIT-P05.md, VERIFY-P03.md, VERIFY-P04.md,
|
||||
P4-PILOT-RUN-EVIDENCE.md, AUTONOMY_THESIS.md, COST.md.
|
||||
|
||||
- **Task 2.2** (lead-developer): grep for dangling references to the
|
||||
archived filenames across `.ciagent/` + `docs/`; fix any in P2 (the
|
||||
fix-stale phase).
|
||||
|
||||
#### Wave 3 — archive consumer file (1)
|
||||
- **Task 3.1** (lead-developer): `mkdir
|
||||
.ciagent/nova-blockchain-exchange/archive/` + `git mv
|
||||
nova-blockchain-exchange/ROADMAP.md` →
|
||||
`nova-blockchain-exchange/archive/ROADMAP-v1.26.md` (D-221).
|
||||
|
||||
#### Wave 4 — commit P1
|
||||
- **Task 4.1** (lead-developer): single commit `chore(P01): archive 7
|
||||
platform + 1 consumer outdated .ciagent files` with `---ci---`
|
||||
block.
|
||||
|
||||
**Must-haves (verify before ship):**
|
||||
- STATE.md 36 rows accurate (Wave 1 verification).
|
||||
- 7 platform files present in `.ciagent/archive/` with milestone
|
||||
suffixes; originals gone from `.ciagent/` root.
|
||||
- 1 consumer file present in
|
||||
`.ciagent/nova-blockchain-exchange/archive/`; original gone.
|
||||
- 0 dangling references in active files (checked in P2, but flagged
|
||||
here).
|
||||
|
||||
### Phase P2 — fix-stale-wire (corrections + wiring)
|
||||
|
||||
**Goal:** Fix 3 stale-but-kept files + wire STATE.md into the P-final
|
||||
ship discipline + add a pointer in the consumer PROJECT.md.
|
||||
|
||||
#### Wave 1 — fix PROJECT.md phase-status
|
||||
- **Task 1.1** (lead-developer): `.ciagent/PROJECT.md` lines 424–431 —
|
||||
the v1.26 phase-status block. Mark P3/P4/P5 complete with shipped
|
||||
tags (`v1.25.3`, `v1.25.4`, `v1.25.5`); mark v1.26 milestone shipped.
|
||||
- **Task 1.2** (lead-developer): add a one-line pointer to STATE.md in
|
||||
the "Capability Status" section header (line 130): "The PO-facing
|
||||
capability catalog is `.ciagent/STATE.md` (additive; updated at
|
||||
milestone ship). CAP-NNN IDs cross-reference the regression gate at
|
||||
`core/regression_verify.py`."
|
||||
|
||||
#### Wave 2 — fix ROADMAP.md phase-status
|
||||
- **Task 2.1** (lead-developer): `.ciagent/ROADMAP.md` v1.26 section —
|
||||
mark P3/P4/P5 complete with shipped tags; mark the v1.26 Overview
|
||||
line (line 181) "(active, ...)" → "(complete, tag `v1.25.5`)".
|
||||
- **Task 2.2** (lead-developer): add STATE.md to the v1.25 + v1.26 P5
|
||||
phase-detail "Updated at ship" list (the convention visibility
|
||||
point).
|
||||
|
||||
#### Wave 3 — wire STATE.md into ship discipline
|
||||
- **Task 3.1** (lead-developer): `.ciagent/PLAN.md` P5 Wave 3 Task 3.5
|
||||
— add STATE.md to the file-update list: "append new capability
|
||||
entries to `.ciagent/STATE.md`; mark any deprecated capability."
|
||||
- **Task 3.2** (lead-developer): `.ciagent/NORTH_STAR.md` — add a
|
||||
one-line note in "Relationship to engineering files" (or the v1.25
|
||||
update section): "STATE.md is the *what exists* catalog (PO-owned,
|
||||
additive, updated at milestone ship); this file is the *why*."
|
||||
|
||||
#### Wave 4 — fix archive README + consumer PROJECT pointer
|
||||
- **Task 4.1** (lead-developer): `.ciagent/archive/README.md` — add
|
||||
the 8 new archived files (7 platform + 1 consumer) to the contents
|
||||
tables (Snapshots + Completed-phase artifacts sections).
|
||||
- **Task 4.2** (lead-developer):
|
||||
`.ciagent/nova-blockchain-exchange/PROJECT.md` — add a one-line
|
||||
pointer to the platform ROADMAP for milestone-phase history (since
|
||||
the consumer ROADMAP is archived): "Phase-by-phase history:
|
||||
`.ciagent/ROADMAP.md` §v1.26 (the consumer ROADMAP is archived at
|
||||
`.ciagent/nova-blockchain-exchange/archive/ROADMAP-v1.26.md`)."
|
||||
|
||||
#### Wave 5 — fix any dangling references from P1 Wave 2
|
||||
- **Task 5.1** (lead-developer): apply fixes for any dangling
|
||||
references found in P1 Wave 2.
|
||||
|
||||
#### Wave 6 — commit P2
|
||||
- **Task 6.1** (lead-developer): single commit `docs(P02): fix stale
|
||||
phase-status + wire STATE.md into ship discipline` with `---ci---`
|
||||
block.
|
||||
|
||||
**Must-haves (verify before ship):**
|
||||
- PROJECT.md v1.26 phase-status matches CHECKPOINT.json (P3/P4/P5
|
||||
complete, v1.26 shipped).
|
||||
- ROADMAP.md v1.26 sections show P3/P4/P5 complete + Overview complete.
|
||||
- PLAN.md P5 Wave 3 names STATE.md.
|
||||
- NORTH_STAR.md notes STATE.md.
|
||||
- archive/README.md lists the 8 new archived files.
|
||||
- nova-blockchain-exchange/PROJECT.md points to platform ROADMAP.
|
||||
|
||||
### Phase P3 — final-review-ship (review + audit + milestone ship)
|
||||
|
||||
**Goal:** Final review + audit + milestone ship.
|
||||
|
||||
#### Wave 1 — review
|
||||
- **Task 1.1** (lead-developer): review all P1/P2 changes for
|
||||
correctness (no broken markdown, no inaccurate citations, no
|
||||
dangling references).
|
||||
- **Task 1.2** (lead-developer): fix any P0 issues in this phase.
|
||||
|
||||
#### Wave 2 — audit
|
||||
- **Task 2.1** (lead-developer): reconstruction test — git log
|
||||
`---ci---` blocks ↔ `.ciagent/` files consistent; phase
|
||||
progression P0→P1→P2→P3.
|
||||
- **Task 2.2** (lead-developer): `.ciagent/` file discipline —
|
||||
CHECKPOINT consistent with HEAD; PROJECT/ROADMAP phase-status
|
||||
consistent with CHECKPOINT; STATE.md present + 36 rows; archive
|
||||
contents match the moves.
|
||||
- **Task 2.3** (lead-developer): branch hygiene — only main +
|
||||
milestone + P3; P1/P2 deleted.
|
||||
- **Task 2.4** (lead-developer): commit discipline — all v1.27 commits
|
||||
carry `---ci---` blocks.
|
||||
|
||||
#### Wave 3 — milestone ship
|
||||
- **Task 3.1** (lead-developer): merge `phase/03` →
|
||||
`milestone/v1.27-po-state-catalog` → `main`.
|
||||
- **Task 3.2** (lead-developer): tag `v1.26.3` (= the v1.27 release per
|
||||
prev-minor tagging rule; v1.27 is an NFR milestone, tags on v1.26.x).
|
||||
- **Task 3.3** (lead-developer): create Gitea release with full
|
||||
milestone summary.
|
||||
- **Task 3.4** (lead-developer): delete all milestone branches (local
|
||||
+ remote). Tags preserve all history.
|
||||
- **Task 3.5** (lead-developer): update `.ciagent/REQUIREMENTS.md`
|
||||
(no REQs to mark — NFR milestone), `.ciagent/ROADMAP.md` (mark
|
||||
v1.27 complete), `.ciagent/NORTH_STAR.md` (no strategic change),
|
||||
`.ciagent/STATE.md` (bump "Last milestone ship" to v1.27).
|
||||
- **Task 3.6** (lead-developer): write checkpoint `stage: complete,
|
||||
phase: 3, phase_role: final` + clear checkpoint (milestone
|
||||
complete).
|
||||
|
||||
**Must-haves (verify before ship):**
|
||||
- Review: 0 P0 issues unfixed; P1+ flagged for post-hoc.
|
||||
- Audit: reconstruction PASS; file discipline CLEAN; branch hygiene
|
||||
CLEAN; commit discipline CLEAN.
|
||||
- Ship: `v1.26.3` tag exists; Gitea release created; milestone
|
||||
branches deleted; main has the milestone merge.
|
||||
|
||||
---
|
||||
|
||||
## Requirement → Phase Mapping
|
||||
|
||||
No REQ-NNN (NFR milestone). The work items are file operations,
|
||||
traced by the Wave tasks above.
|
||||
|
||||
---
|
||||
|
||||
## Wave Ordering Rationale
|
||||
|
||||
- **P1 W1 → W2:** verify STATE.md before archiving (the archive removes
|
||||
the source-of-truth CAPABILITY_INVENTORY; STATE.md must be accurate
|
||||
first).
|
||||
- **P1 W2 → W3:** platform archive before consumer archive (the
|
||||
platform archive pattern is established; the consumer archive
|
||||
creates a new subdir).
|
||||
- **P2 W1 → W2 → W3:** PROJECT.md fix before ROADMAP.md fix before
|
||||
ship-discipline wiring (PROJECT is the source-of-truth narrative;
|
||||
ROADMAP mirrors it; PLAN/NORTH_STAR wire the convention).
|
||||
- **P2 W4:** archive README + consumer pointer (cross-cutting; lands
|
||||
after the active-file fixes).
|
||||
- **P2 W5:** dangling-reference fixes (lands after all moves + edits
|
||||
are known).
|
||||
|
||||
---
|
||||
|
||||
## Vertical-slice integrity
|
||||
|
||||
Each phase ships a self-contained, verifiable slice:
|
||||
- P1 ships STATE.md (verified accurate) + 8 archived files (verified
|
||||
moved). The active `.ciagent/` root drops from 25 to 17 files.
|
||||
- P2 ships 3 fixed files + 3 wired files + archive README + consumer
|
||||
pointer. The kept files match CHECKPOINT.json state.
|
||||
- P3 ships the milestone release + cleared checkpoint.
|
||||
@@ -129,7 +129,11 @@ human at stage gates" model from the NORTH_STAR.
|
||||
|
||||
## Capability Status (Re-Verified 2026-07-27)
|
||||
|
||||
> Source of truth: `.ciagent/CAPABILITY_INVENTORY.md` (Phase 54, D-093).
|
||||
> **PO-facing capability catalog:** `.ciagent/STATE.md` (additive;
|
||||
> updated at milestone ship). CAP-NNN IDs cross-reference the regression
|
||||
> gate at `core/regression_verify.py`.
|
||||
> Source of truth (the 2026-07-27 sweep, archived v1.27):
|
||||
> `.ciagent/archive/CAPABILITY_INVENTORY-v1.10.md` (Phase 54, D-093).
|
||||
> Tier: **local** = runs via emulating adapters (no AWS); **live-aws** =
|
||||
> runs against the live AWS account (581513795199).
|
||||
|
||||
@@ -156,7 +160,7 @@ service live, CloudFront production stack, uptime-kuma, OIDC role). The
|
||||
code would deploy them; the local emulators (Phase 53) prove the runtime
|
||||
behavior. Re-bootstrap of the OIDC role + IAM re-grant requires an admin
|
||||
principal — escalated, not silently skipped. See
|
||||
`CAPABILITY_INVENTORY.md` §"Cloud capabilities NOT re-verified".
|
||||
`CAPABILITY_INVENTORY-v1.10.md` §"Cloud capabilities NOT re-verified".
|
||||
|
||||
**Regression gate.** `bash scripts/run_regression.sh` re-runs all 16
|
||||
auto-verifiable capabilities and fails closed on any non-Verified result.
|
||||
@@ -340,7 +344,7 @@ plan-JSON policies + pipeline wiring (REQ-300,301,302), meta-policies
|
||||
(REQ-303), regression-gate policies (REQ-304,305), docs + adapter README
|
||||
(REQ-306,307), tests (REQ-308,309).
|
||||
|
||||
## v1.26 — Live Pilot Estate Activation (active)
|
||||
## v1.26 — Live Pilot Estate Activation (complete, tag `v1.25.5`, merged to main 2026-08-19)
|
||||
|
||||
> **Active milestone.** Feature milestone — the first real consumer estate
|
||||
> (a stock exchange on a homegrown PoA blockchain, equities only) is
|
||||
@@ -421,14 +425,717 @@ already exist).
|
||||
- The consumer deploy MUST go through `deploy.yml@v1.25` — no direct
|
||||
`terraform apply` bypassing the platform's gates.
|
||||
|
||||
### v1.26 phase status (live — see CHECKPOINT.json for the authoritative state)
|
||||
### v1.26 phase status (shipped — tag `v1.25.5` = the v1.26 release, merged to main 2026-08-19)
|
||||
|
||||
- **P0** pre-execution (SPECIFY→CLARIFY→RESEARCH→IDEATE→PLAN→GRILL) — complete, tag `v1.25.0`.
|
||||
- **P1** blockchain-core (REQ-310,311,312) — complete, tag `v1.25.1`.
|
||||
- **P2** consumer-contract-and-deploy (REQ-313,314,322) — complete, tag `v1.25.2`.
|
||||
- **P3** pilot-metrics-and-policies (REQ-315,316,317,318,319,320) — pending.
|
||||
- **P4** pilot-run-and-docs (REQ-316,321) — pending.
|
||||
- **P5** final review + audit + milestone ship — pending. Tag `v1.25.5` = the v1.26 release.
|
||||
- **P3** pilot-metrics-and-policies (REQ-315,316,317,318,319,320) — complete, tag `v1.25.3`.
|
||||
- **P4** pilot-run-and-docs (REQ-316,321) — complete, tag `v1.25.4` (live apply against `581513795199` succeeded; confidence 0.800 pass; outcome backfilled).
|
||||
- **P5** final review + audit + milestone ship — complete, tag `v1.25.5` = the v1.26 release (PROCEED; 0 P0 remain; audit CLEAN; merged to main).
|
||||
|
||||
> Phase-by-phase task breakdown, wave ordering, and persona assignments
|
||||
> live in `.ciagent/PLAN.md` (the active phase plan, retained in full).
|
||||
> live in `.ciagent/PLAN.md` (the active phase plan, retained in full).
|
||||
> v1.26 pre-execution artifacts (CLARIFY/GRILL/IDEATE/RESEARCH) are in
|
||||
> git history (pre-v1.27-P0 commits); the v1.26 phase verifications +
|
||||
> review are archived at `.ciagent/archive/{VERIFY-P03,VERIFY-P04,REVIEW-AUDIT-P05}.md`.
|
||||
|
||||
## v1.27 — PO State Catalog & Ciagent Compression (complete, tag `v1.26.3`, merged to main 2026-08-19)
|
||||
|
||||
> **NFR milestone — complete.** STATE.md authored (32 CAPs, 11 invariants,
|
||||
> 10 domains). 8 outdated `.ciagent/` files archived (7 platform + 1
|
||||
> consumer). PROJECT.md + ROADMAP.md v1.26 phase-status corrected.
|
||||
> STATE.md wired into P-final ship discipline. Tags: `v1.26.0` (P0) →
|
||||
> `v1.26.1..v1.26.2` (P1..P2) → `v1.26.3` (P3 final = milestone release).
|
||||
> Review: 0 P0. Audit: reconstruction PASS, file/branch/commit discipline CLEAN.
|
||||
> Full phase detail: `.ciagent/archive/` (v1.27 artifacts) + git history.
|
||||
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (complete, tag `v1.27.6`, merged to main 2026-08-19)
|
||||
|
||||
> **Feature milestone — complete.** The Nova CLI becomes installable from
|
||||
> internal PyPI (CodeArtifact), every `core/` module is reachable as a
|
||||
> `nova <subcommand>`, the CLI and Lambda functions share a single
|
||||
> `core/` source tree, and Nova owns its identity layer end-to-end
|
||||
> (sign-up through token vending) with no AWS-managed identity services
|
||||
> in the path. Nova-idp is introduced: two Lambda functions (`nova-idp-auth`,
|
||||
> `nova-idp-token-vend`), KMS-signed OIDC tokens, ABAC-gated token vending
|
||||
> via the existing kyverno-json engine (INV-4 swappable), and PAT
|
||||
> lifecycle (issuance, revocation, status).
|
||||
>
|
||||
> Tags run on the **v1.27.x** line: `v1.27.0` (P0) → `v1.27.1..v1.27.N`
|
||||
> (execution phases) → `v1.27.(N+1)` (final phase = milestone release).
|
||||
> Milestone branch: `milestone/v1.28-cli-identity`.
|
||||
|
||||
### v1.28 ID allocations (re-mapped — no collisions with shipped history)
|
||||
|
||||
- **Decisions:** `D-226..D-231` (authored in CLARIFY). Repo decision
|
||||
namespace is `D-NNN` (max D-225); no `D-NEW-*` namespace exists.
|
||||
- **Requirements:** `REQ-323..REQ-353` (31 REQs, mapping the spec's
|
||||
REQ-001..REQ-031 1:1). Max existing REQ = REQ-322.
|
||||
- **Capabilities:** `CAP-033..CAP-038` (mapping the spec's CAP-025..CAP-030).
|
||||
Existing CAP-025..032 are blockchain/pilot — collision avoided.
|
||||
- **Invariants:** `INV-12..INV-17` (mapping the spec's INV-63,64,65,18..21,34).
|
||||
Max existing INV = INV-11.
|
||||
- **`kj` engine → kyverno-json.** The spec references a `kj` engine; the
|
||||
repo's actual policy engine is `kyverno-json` (INV-4 swappable). v1.28
|
||||
uses kyverno-json as the ABAC evaluator for token-vend; no new `kj`
|
||||
engine is built. This is a CLARIFY-grounded re-mapping, not a silent
|
||||
assumption (D-229).
|
||||
|
||||
### v1.28 Requirements
|
||||
|
||||
New requirements REQ-323..REQ-353 — full text in
|
||||
`.ciagent/REQUIREMENTS.md` §v1.28. Summary by priority:
|
||||
|
||||
- **P1 — CLI Substrate (REQ-323..REQ-328):** CodeArtifact wheel + Lambda
|
||||
layer pipeline; CLI subcommand per `core/` module; `nova init`
|
||||
scaffolding; `nova cli-action` published to GitHub + Gitea;
|
||||
`mode_resolver.py` (flag → env → credential type → TTY); audit
|
||||
emission with `mode` + `selection_reason`.
|
||||
- **P2 — Lambda Packaging + Identity Layer (REQ-329..REQ-344):** dual-use
|
||||
`core/lambda/contract_ingestor.py`; local env synthesizer; JWS signing
|
||||
key from PAT; `nova-idp-auth` Lambda (Argon2id, DynamoDB); DynamoDB
|
||||
tables (`nova-users`, `nova-sessions`, `nova-password-resets`);
|
||||
`nova-idp-token-vend` Lambda (KMS-signed OIDC, JWKS endpoint); kyverno-json
|
||||
ABAC policy at `platform/abac/token-vend.policy`; `nova idp setup`
|
||||
(`--check/--apply/--verify`); CloudFormation review; PAT issuance +
|
||||
hashes in DynamoDB; `nova auth login/revoke/status`.
|
||||
- **P3 — Documentation (REQ-345..REQ-347):** operator guide for
|
||||
`nova idp setup`; developer guide for `nova auth login`; identity-layer
|
||||
threat model.
|
||||
- **P4 — Integration Testing (REQ-348..REQ-351):** E2E sign-up → sign-in →
|
||||
token-vend → apply → audit; property tests for `mode_resolver`; KMS
|
||||
round-trip test; PAT revocation SLO test (≤60s P95).
|
||||
- **P5 — Capability Gate (REQ-352..REQ-353):** CAP-033..038 verification
|
||||
gates wired into CI.
|
||||
|
||||
### v1.28 Hard constraints
|
||||
|
||||
- DO NOT depend on Cognito, IAM Identity Center, or any AWS-managed
|
||||
identity service for sign-up/sign-in/token-vending (NFR-5). Nova-idp
|
||||
signs OIDC tokens directly via KMS. (Note: no Cognito exists in the
|
||||
repo today — this is a greenfield build, not a "Cognito drop".)
|
||||
- DO NOT build a new `kj` engine — use kyverno-json (INV-4).
|
||||
- DO NOT enforce MFA/TOTP for prod/dr this milestone — ship the code path,
|
||||
enforce in v1.21+ (deferred, INV scope).
|
||||
- DO NOT add WebAuthn/FIDO2, upstream IdP federation, or password breach
|
||||
detection — deferred to v1.23+.
|
||||
- DO NOT add Lambda layer auto-update on `core/` changes — v1.18 ships
|
||||
manual `nova layer update`; v1.19 adds CI-triggered auto-update.
|
||||
- The token-vend Lambda MUST evaluate the kyverno-json ABAC policy before
|
||||
signing; allow/deny decisions MUST be emitted to the audit stream
|
||||
(NFR-9, D-227).
|
||||
- `nova idp setup --apply` MUST present the CloudFormation template for
|
||||
review before any resource is created (NFR-10).
|
||||
|
||||
### v1.28 phase status (complete — tag `v1.27.6` = the v1.28 release)
|
||||
|
||||
- **P0** pre-execution → `v1.27.0` (complete).
|
||||
- **P1..P5** execution phases → `v1.27.1..v1.27.5` (complete).
|
||||
- **P6** final review + audit + milestone ship → `v1.27.6` = the v1.28
|
||||
release (complete, merged to main 2026-08-19).
|
||||
|
||||
> Phase-by-phase task breakdown, wave ordering, and persona assignments:
|
||||
> `.ciagent/PLAN.md` (retained). Authoritative resume state:
|
||||
> `.ciagent/CHECKPOINT.json`.
|
||||
|
||||
---
|
||||
|
||||
## v1.29 — Reposplit + Identity Layer Bring-Live (complete, tag `v1.28.6`, merged to main 2026-08-20)
|
||||
|
||||
> **Feature milestone — complete.** v1.29 extracts all live platform
|
||||
> components (Nova-idp Lambdas, KMS keys, DynamoDB tables, S3 state
|
||||
> buckets, OIDC roles, JWKS, audit outbox bootstrap) from `acdl/acdl`
|
||||
> into a dedicated Gitea-private Terraform repository
|
||||
> (`nova-platform-ops`), brings Nova-idp live in account `581513795199`
|
||||
> for the first time (code complete since v1.28, unverified-in-account at
|
||||
> Phase 0), and standardizes `acdl/acdl` on GitHub. The split enforces
|
||||
> Vision §4 domain boundaries architecturally: engineering ends at the
|
||||
> compiled artifact; operations begins at the live platform under
|
||||
> guardrails. Vision §5 "Narrow capability interfaces" shapes the
|
||||
> substrate design — `kj` has exactly one identity (one ECR image
|
||||
> digest), shared by both the production runtime and its defensive
|
||||
> fallback, eliminating drift by construction (KJ-LOCKSTEP).
|
||||
|
||||
### Scope split (CLARIFY-grounded, full autonomy)
|
||||
|
||||
The spec creates a **separate** Gitea-private repo `nova-platform-ops`.
|
||||
CIAgent runs inside `acdl`. The Terraform module code
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`) is
|
||||
authored **out-of-band** in `nova-platform-ops` (operator-owned). CIAgent
|
||||
in `acdl` delivers only the acdl-side work and tracks the ops-side REQs
|
||||
as **covered-reference** (verification surface = the M1/M1.5/M2 cutover
|
||||
gates documented in the operator guide, not a missing test).
|
||||
|
||||
| In-acdl (CIAgent authors) | Covered-reference (nova-platform-ops) |
|
||||
|---|---|
|
||||
| REQ-354 (publish.yml + ECR image + Release) | REQ-355, 356, 357, 358 (ops CI/HITL/pin) |
|
||||
| REQ-367 (Gitea scrub) | REQ-359 (Gitea-private repo) |
|
||||
| REQ-368 (decisions D-232..238) | REQ-360 (IAM scope bounded) |
|
||||
| REQ-369 (CFN archive + CLI `--apply` TF delegation) | REQ-361 (import idempotency) |
|
||||
| Operator guide `docs/operator-guide-platform-ops.md` | REQ-362 (KMS key provisioning) |
|
||||
| `platform/abac/kj-version.txt` | REQ-363, 363b (Lambda/Fargate substrate) |
|
||||
| M1.5 verification gate tests | REQ-364, 365, 366 (JWKS/WAF/ACM edge) |
|
||||
| nova-blockchain-exchange deploy.yml @v1.29 bump | REQ-371 `lifecycle.precondition` (TF-side) |
|
||||
|
||||
### v1.29 ID allocations (no collisions with shipped history)
|
||||
|
||||
- **Requirements:** `REQ-354..REQ-369` + `REQ-371` + `REQ-363b` (note:
|
||||
REQ-370 is intentionally unused per the source spec). Max existing REQ
|
||||
= REQ-353. REQ-363b is a sub-requirement of REQ-363 (Fargate defensive
|
||||
fallback, same ECR image — KJ-LOCKSTEP).
|
||||
- **Decisions:** `D-232..D-238` (7 decisions, authored in CLARIFY) +
|
||||
`D-239..D-240` (2 research-derived spec corrections). Max existing D
|
||||
= D-231.
|
||||
- **Invariants:** `INV-18` (JWKS-EDGE-ONLY — proposed in spec §5, promoted
|
||||
here). Plus non-invariant NFRs carried as constraints: KJ-STATIC,
|
||||
KJ-LOCKSTEP, KJ-WARMUP-HEALTH, OPER-PRIV, IAM-NARROW, DRIFT-DETECT,
|
||||
IMPORT-IDEMPOTENT, TFM-HITL, JWKS-SLO, JWKS-ROTATION. Max existing INV
|
||||
= INV-17.
|
||||
- **`kj` here is the Go binary** (`platform/abac/kj-version.txt`, pinned
|
||||
v0.0.3), NOT the kyverno-json engine. v1.28 re-mapped the spec's `kj`
|
||||
engine → kyverno-json (D-227). v1.29 reintroduces `kj` as a **compiled
|
||||
Go binary** embedded in the ECR container image — a distinct artifact.
|
||||
No collision: kyverno-json remains the policy engine (INV-4); `kj` is a
|
||||
static binary invoked via `subprocess` by the Lambda handler.
|
||||
|
||||
### v1.29 Requirements
|
||||
|
||||
New requirements REQ-354..REQ-369 + REQ-371 + REQ-363b — full text in
|
||||
`.ciagent/REQUIREMENTS.md` §v1.29. Summary by phase:
|
||||
|
||||
- **P1 — Publish Pipeline (REQ-354):** `publish.yml` attaches Lambda zip
|
||||
+ layer wheel + Python wheel + ECR container image (static `kj`,
|
||||
`CGO_ENABLED=0`, tag `v1.29.x+kj-<sha>`) to GitHub Release with SHA-256.
|
||||
- **P2 — Gitea Scrub + Decisions (REQ-367, REQ-368):** remove `.gitea/`,
|
||||
scrub all Gitea refs, `forge_parity_disabled` CI assertion, pyproject
|
||||
→ 1.29.0, record D-232..238.
|
||||
- **P3 — CFN Archive + TF Delegation (REQ-369):** archive CFN template →
|
||||
`docs/archive/nova-idp-cfn-v1.28.md`, `nova idp setup --apply` delegates
|
||||
to `terraform apply`.
|
||||
- **P4 — Operator Guide + Reference Tracking:** `docs/operator-guide-
|
||||
platform-ops.md`, ARCHITECTURE.md §12.9, STATE.md v1.29 CAPs +
|
||||
invariants; REQUIREMENTS.md covered-reference markers.
|
||||
- **P5 — Consumer Deploy Bump (cross-project, Edge 8):** `nova-
|
||||
blockchain-exchange` deploy.yml `@v1.25` → `@v1.29` + smoke test.
|
||||
- **P6 — Final Review + Audit + Milestone Ship.**
|
||||
|
||||
### v1.29 Hard constraints
|
||||
|
||||
- DO NOT activate pilot qa/prod/dr environments (D-208/D-209 — separate
|
||||
initiative). M1 brings Nova-idp live; env activation is out.
|
||||
- DO NOT add S3 Object Lock / JWS tamper-resistance (D-083). Tamper-
|
||||
evidence via SQLite hash-chain remains.
|
||||
- DO NOT restore 73.8% coverage — separate NFR milestone; YELLOW carried
|
||||
without scope expansion.
|
||||
- DO NOT provision CodeArtifact — direct GitHub Releases artifact fetch.
|
||||
- DO NOT delete the CFN template in `acdl/acdl` at v1.29.0 — archive as
|
||||
read-only reference (`docs/archive/nova-idp-cfn-v1.28.md`); deletion is
|
||||
a follow-up after Terraform parity is verified.
|
||||
- DO NOT add Nova-idp feature work (new OIDC claims, new ABAC rules) —
|
||||
bring live; don't extend.
|
||||
- DO NOT add MFA/TOTP, WebAuthn, upstream IdP federation (Vision §7).
|
||||
- DO NOT add a CloudFront Frontend (L3B consumer surface) — pure ops
|
||||
focus only.
|
||||
- The `kj` binary MUST be compiled `CGO_ENABLED=0` and verified statically
|
||||
linked (`file(1)`) before embedding (KJ-STATIC).
|
||||
- The ECR image digest on the Fargate standby MUST equal the Lambda
|
||||
`image_uri` digest at every `terraform plan` (KJ-LOCKSTEP, REQ-371 —
|
||||
fail-closed by `lifecycle.precondition` mechanism, not by discipline).
|
||||
- The JWKS endpoint is the ONLY public read surface; all other platform
|
||||
endpoints gate with `AuthType: AWS_IAM` (JWKS-EDGE-ONLY, INV-18).
|
||||
- Any `terraform apply` against `main` in `nova-platform-ops` MUST require
|
||||
a Gitea Actions approval from a user distinct from the PR author
|
||||
(TFM-HITL, INV-3 applied at platform level).
|
||||
- `nova-platform-ops` MUST be `private: true` in Gitea, not mirrored
|
||||
(OPER-PRIV).
|
||||
|
||||
### v1.29 phase status (complete — tag `v1.28.6` = the v1.29 release)
|
||||
|
||||
- **P0** pre-execution → `v1.28.0` (complete).
|
||||
- **P1..P5** execution phases → `v1.28.1..v1.28.5` (complete).
|
||||
- **P6** final review + audit + milestone ship → `v1.28.6` = the v1.29
|
||||
release (complete, merged to main 2026-08-20).
|
||||
|
||||
> Tags run on the **v1.28.x** line: `v1.28.0` (P0) →
|
||||
> `v1.28.1..v1.28.5` (execution phases) → `v1.28.6` (final phase =
|
||||
> milestone release). Milestone branch:
|
||||
> `milestone/v1.29-reposplit-identity`. Phase-by-phase task breakdown,
|
||||
> wave ordering, and persona assignments will live in `.ciagent/PLAN.md`.
|
||||
> Authoritative resume state: `.ciagent/CHECKPOINT.json`.
|
||||
|
||||
## v1.30 — Single-shot Leadership Deck (active, presented August 2026)
|
||||
|
||||
> **Feature milestone — single-shot PPTX leadership deck.** A
|
||||
> hand-authored Marp markdown deck rendered via the existing
|
||||
> `scripts/render_pptx.py` pipeline, presented live to Infrastructure
|
||||
> & Operations leadership (CTO + VP Technology + Product Management)
|
||||
> in August 2026, securing architecture endorsement and a November
|
||||
> 2026 runway to demonstrate Nova's next milestone. The deck is a
|
||||
> **discrete artifact** (D-241: NOT a compression of the existing
|
||||
> citizen-developer pitch `nova-autonomous-cloud-delivery-marp.md`,
|
||||
> which remains untouched).
|
||||
>
|
||||
> Tags run on the **v1.29.x** line: `v1.29.1` (P0) → `v1.29.2` (P1
|
||||
> execution) → `v1.29.3` (P2 final review = milestone release).
|
||||
> Milestone branch: `milestone/v1.30-leadership-deck`. Single execution
|
||||
> phase (P1) — this is a single-shot artifact, not a multi-phase
|
||||
> build. Authoritative resume state: `.ciagent/CHECKPOINT.json`.
|
||||
>
|
||||
> **Source spec:** REQ-372 v1.0 (locked 2026-08-20). Full requirement
|
||||
> text in `.ciagent/REQUIREMENTS.md` §v1.30. Slide Content Map (the
|
||||
> source-of-truth for REQ-372.7 content traceability) is reproduced
|
||||
> verbatim below from the locked spec.
|
||||
|
||||
### v1.30 ID allocations (no collisions with shipped history)
|
||||
|
||||
- **Decisions:** `D-241..D-246` (6 decisions, authored in CLARIFY
|
||||
across P0 + P3). Max existing D = D-240 (v1.29). Next free: D-247.
|
||||
- **D-241** — Leadership deck is a single-shot, discrete, hand-
|
||||
authored artifact (NOT a compression of the existing citizen-
|
||||
developer pitch `nova-autonomous-cloud-delivery-marp.md`).
|
||||
Audience: Infrastructure & Operations leadership (CTO + VP
|
||||
Technology + Product Management). August 2026 presentation
|
||||
anchor + November 2026 runway anchor. The existing citizen-
|
||||
developer deck remains untouched. Overrides the post-v1.29
|
||||
STATE.md intake assumption 3.
|
||||
- **D-242** — Narrow `scripts/render_pptx.py` extension: accept an
|
||||
explicit source `.md` path + `--output` filename; render a
|
||||
right-aligned footer textbox on every slide (python-pptx does
|
||||
not read the Marp `footer:` directive). Non-REQ-372 prerequisite
|
||||
per spec §3.3 Edge 2.
|
||||
- **D-243** — Date anchor discipline: August 2026 is a month-only
|
||||
presentation anchor (no specific day); November 2026 is the
|
||||
runway anchor (~90 days).
|
||||
- **D-244** — Slide 1 becomes a black-background cover (overrides
|
||||
grill G-1). Visual treatment only; on-slide text unchanged.
|
||||
- **D-245** — Slide 7 "What works now" content addition. New
|
||||
on-slide block before the ask. Records: 13 L1 + 2 L2 live
|
||||
(confidence 0.800), next-steps arc (greenfield pilots → promote
|
||||
to prod → SPGE constitutional library → infra layer). "sandbox"
|
||||
is colloquial for the dev AWS account `581513795199`; "SPGE
|
||||
constitutional library" is external/unverified-in-repo.
|
||||
- **D-246** — 7 S&P-themed mermaid diagrams, one per slide,
|
||||
rendered via local mmdc with vendored Chromium libs (24 Debian
|
||||
bookworm .deb packages fetched, `.so` files extracted). `.mmd`
|
||||
sources + PNGs committed for reproducibility.
|
||||
- **Capabilities:** `CAP-042` (1 capability, appended at ship).
|
||||
Max existing CAP = CAP-041 (v1.29). Next free: CAP-043.
|
||||
|
||||
### v1.30 Scope (CLARIFY-grounded, full autonomy)
|
||||
|
||||
- **In scope:** one hand-authored Marp source
|
||||
(`docs/presentations/nova-leadership-deck-marp.md`, ≤7 slides); one
|
||||
rendered PPTX (`docs/presentations/nova-leadership-deck.pptx` via
|
||||
the existing `scripts/render_pptx.py`, narrowly extended per
|
||||
D-242); speaker notes per slide meeting the depth discipline
|
||||
(REQ-372.4); footer `Nova Platform - Infrastructure & Operations`
|
||||
on all 7 slides (REQ-372.5); S&P theme tokens only (REQ-372.6);
|
||||
related-artifacts header comment (REQ-372.9); smoke test
|
||||
`scripts/check_leadership_deck.sh` runnable on demand, NOT a CI
|
||||
gate (REQ-372.8); vision `[1]` grounding citations in slides 3/5/7
|
||||
speaker notes (REQ-372.12); CAP-042 in STATE.md (REQ-372.10);
|
||||
D-241 record in PROJECT.md (REQ-372.11).
|
||||
- **Out of scope (explicit exclusions):** compression/modification of
|
||||
the existing citizen-developer deck; per-milestone refresh / auto-
|
||||
regeneration; Marp HTML as a primary deliverable; multi-audience
|
||||
variants; `publish.yml` integration; live AWS cutover of covered-
|
||||
reference REQs; coverage floor restoration; S3 Object Lock
|
||||
provisioning; roadmap authoring (PLAN.md remains source of record);
|
||||
new CI plumbing.
|
||||
|
||||
### v1.30 Requirements
|
||||
|
||||
Full text in `.ciagent/REQUIREMENTS.md` §v1.30. Summary:
|
||||
|
||||
- **REQ-372.1** — Source markdown exists and is parseable (7 slides,
|
||||
header comment).
|
||||
- **REQ-372.2** — PPTX render via existing pipeline (7 slides, no
|
||||
python-pptx exceptions).
|
||||
- **REQ-372.3** — Slide count is exactly 7.
|
||||
- **REQ-372.4** — Speaker notes depth per slide (word bands: 1/2/4/6
|
||||
150–300; 3/5 250–400; 7 200–300).
|
||||
- **REQ-372.5** — Footer `Nova Platform - Infrastructure & Operations`
|
||||
on every slide (right-aligned).
|
||||
- **REQ-372.6** — Only S&P theme tokens `#D6002A`, `#1B1B1B`,
|
||||
`#FFFFFF`, `#F0F0F0`.
|
||||
- **REQ-372.7** — Slide-by-slide content matches the Slide Content Map
|
||||
(visual review).
|
||||
- **REQ-372.8** — Smoke test `scripts/check_leadership_deck.sh` exits
|
||||
0 on pass (asserts a–f). Runnable on demand; NOT a CI gate.
|
||||
- **REQ-372.9** — Related-artifacts comment in source header.
|
||||
- **REQ-372.10** — CAP-042 appended to STATE.md at ship.
|
||||
- **REQ-372.11** — D-241 recorded in PROJECT.md at ship.
|
||||
- **REQ-372.12** — Vision `[1]` citations in slides 3, 5, 7 speaker
|
||||
notes (ground to `docs/vision.md`).
|
||||
|
||||
### v1.30 Hard constraints
|
||||
|
||||
- **DO NOT modify** `docs/presentations/nova-autonomous-cloud-delivery-marp.md`
|
||||
(the citizen-developer pitch). Per D-241, the two decks remain
|
||||
discrete artifacts.
|
||||
- **DO NOT add `publish.yml` integration** for this deck. Not tagged
|
||||
or released via the existing pipeline.
|
||||
- **DO NOT wire `scripts/check_leadership_deck.sh` as a CI gate.**
|
||||
Runnable on demand. Single-shot artifact.
|
||||
- **DO NOT extend the deck beyond 7 slides.** Slide count bound by
|
||||
REQ-372.3.
|
||||
- **DO NOT auto-derive future leadership decks** from STATE.md /
|
||||
NORTH_STAR.md. Every leadership artifact is hand-authored.
|
||||
- **DO NOT compress the deck for a sub-audience.** Multi-audience
|
||||
variants are out of scope.
|
||||
- **DO NOT introduce hex colors** outside the 4 S&P theme tokens.
|
||||
|
||||
### v1.30 Authoring conventions
|
||||
|
||||
- **Marp frontmatter:** `marp: true; theme: default; footer: "Nova
|
||||
Platform - Infrastructure & Operations"; paginate: false; size: 16:9`
|
||||
- **Theme tokens (only colors in source):** `#D6002A`, `#1B1B1B`,
|
||||
`#FFFFFF`, `#F0F0F0`
|
||||
- **Slide separator:** `---` on its own line
|
||||
- **Speaker notes:** HTML comments `<!-- ... -->` within the slide
|
||||
body, before the next `---`
|
||||
- **Footer:** exact string `Nova Platform - Infrastructure &
|
||||
Operations` via the Marp `footer:` directive (and rendered as a
|
||||
right-aligned textbox per D-242, since python-pptx does not read
|
||||
the Marp footer directive)
|
||||
- **Per-slide word-count bands:** slides 1/2/4/6 in 150–300; slides
|
||||
3/5 in 250–400; slide 7 in 200–300
|
||||
- **Vision grounding:** slides 3, 5, 7 speaker notes must contain at
|
||||
least one `[1]` citation grounding to the principles, anti-goals,
|
||||
or tenets in `docs/vision.md`
|
||||
|
||||
### v1.30 Render pipeline (existing — narrowly extended per D-242)
|
||||
|
||||
```bash
|
||||
python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md \
|
||||
--output docs/presentations/nova-leadership-deck.pptx
|
||||
```
|
||||
|
||||
The existing `scripts/render_pptx.py` is extended to accept an
|
||||
explicit source `.md` path + `--output` filename (D-242). 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. The renderer is also
|
||||
extended to add a right-aligned footer textbox on every slide (the
|
||||
python-pptx path does not read the Marp `footer:` directive).
|
||||
|
||||
### v1.30 phase status (live — tag `v1.29.3` = the v1.30 release)
|
||||
|
||||
- **P0** pre-execution → `v1.29.1` (in progress).
|
||||
- **P1** execution (author + render + smoke test) → `v1.29.2`.
|
||||
- **P2** final review + audit + milestone ship → `v1.29.3` = the
|
||||
v1.30 release.
|
||||
|
||||
### v1.30 Slide Content Map (REQ-372.7 traceability reference)
|
||||
|
||||
The PPTX content is fully specified by the slide drafts below. Each
|
||||
slide carries an exact on-slide body + speaker notes fingerprint.
|
||||
Smoke test does not assert content strings verbatim (brittle); audit
|
||||
verifies by visual review against this map. Any drift requires
|
||||
`CLARIFY`.
|
||||
|
||||
#### Slide 1 — The frictions Nova absorbs
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **The friction every delivery team lives today**
|
||||
>
|
||||
> *Velocity is up; the coordination surface around each change is up
|
||||
> faster.*
|
||||
>
|
||||
> → Infrastructure is authored by people who don't specialize in
|
||||
> infrastructure.
|
||||
> → Every change is gated because one misconfiguration can expose the
|
||||
> entire estate.
|
||||
> → Compliance, security, and NFRs are checked late — fueling
|
||||
> remediation cycles that erode delivery cadence and team morale.
|
||||
>
|
||||
> *Nova absorbs all three — owned building blocks, separation of
|
||||
> concerns, attested compliance up front.*
|
||||
|
||||
**Speaker notes (~270 words):** Three-pattern problem frame grounded
|
||||
in the binding-constraint claim [1]. Closing distinguishes
|
||||
**infrastructure patching (Nova's lane)** from **AppSec (application
|
||||
team's lane)** — Nova is not a remediation tool, not a security
|
||||
blanket.
|
||||
|
||||
#### Slide 2 — Nova in one frame
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **Nova in one frame**
|
||||
>
|
||||
> *You already recognize this pattern.*
|
||||
>
|
||||
> Every Central IT team curates a golden image for Windows, for Linux,
|
||||
> for macOS. They own it. They patch it. They ship it. Consumers
|
||||
> consume it without thinking about what's inside.
|
||||
>
|
||||
> Nova plays the same role one layer up — for everything that runs
|
||||
> your cloud. S3 buckets with SSE-KMS posture. RDS instances with
|
||||
> deletion protection and PITR. Lambda containers with static ABAC
|
||||
> binaries. ALBs, ECS services, KMS keys, DynamoDB tables. Each one
|
||||
> is owned by the platform team, patched by the platform team,
|
||||
> attested by the platform team, and consumed by anyone who declares a
|
||||
> contract.
|
||||
>
|
||||
> The difference: every primitive is versioned, tested across its
|
||||
> entire lifecycle, and bounded by policy before any consumer ever
|
||||
> touches it.
|
||||
>
|
||||
> *Nova's lane is the infrastructure beneath the application. AppSec,
|
||||
> dependency review, and runtime application security stay where they
|
||||
> have always been — with the application team.*
|
||||
|
||||
**Speaker notes (~210 words):** Trade-off pattern (Central IT vs.
|
||||
Nova both trade per-application control for uniform operability);
|
||||
platform-begins/ends framing [1]; sovereignty-via-boundary argument.
|
||||
|
||||
#### Slide 3 — Two principles that organize everything else
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **Two principles that organize everything else**
|
||||
>
|
||||
> *The architecture is principled, not improvised. Two tenets
|
||||
> discipline every other decision.*
|
||||
>
|
||||
> **Sovereign boundary.** Nova governs the delivery lifecycle; it
|
||||
> does not reach upstream into product or software development [1].
|
||||
> Integration with SDLC and PDLC partners happens exclusively through
|
||||
> the validated, published contract surface. What lives outside the
|
||||
> contract is not Nova's domain.
|
||||
>
|
||||
> **Lower autonomous · higher attested.** Lower environments proceed
|
||||
> through agentic automation. Promotion to higher environments
|
||||
> requires deliberate human attestation — not as a rubber stamp, but
|
||||
> as policy-mandated accountability [1]. The compute the platform
|
||||
> makes; the choice the human keeps.
|
||||
>
|
||||
> *Everything else in the architecture inherits from these two.*
|
||||
|
||||
**Speaker notes (~270 words):** Cross-tenet architecture discipline
|
||||
argument — how the four-layer model, HITL gates, policy envelope, and
|
||||
contract schema all inherit from the two tenets [1]. Closes with "The
|
||||
next slide is what the line looks like in 18 months of milestones."
|
||||
|
||||
#### Slide 4 — Live · Attested · Stays human
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **Live today**
|
||||
> 41 capabilities across 12 domains. Contract ingestor, audit
|
||||
> outbox, state buckets, and the live pilot run have been operating
|
||||
> in our AWS estate since v1.7; pilot evidence at v1.26 returned
|
||||
> confidence 0.800. DORA + adoption + policy-conformance metrics
|
||||
> flow to PowerBI from the same audit stream as the lineage. Every
|
||||
> finding carries one owner, one patch state, one audit entry — one
|
||||
> pane, no second source of truth. A POC is production-grade by
|
||||
> construction: there is no "POC that became prod" surprise.
|
||||
>
|
||||
> **Attested on promotion**
|
||||
> qa, prod, and dr require a named human approver distinct from the
|
||||
> PR author. Rubber stamps cannot be silently issued.
|
||||
>
|
||||
> **Stays human — by design**
|
||||
> Confidence below the autonomy threshold at qa, prod, or dr triggers
|
||||
> human escalation [1]. Some categories of decision are preserved for
|
||||
> human judgment, and the platform says so out loud.
|
||||
|
||||
**Speaker notes (~230 words):** Three-column claim disambiguation
|
||||
(real / observable / disciplined). Pilot evidence as record, not
|
||||
forecast. Single-pane-of-glass via audit lineage [1]. POC-to-prod
|
||||
discipline [1]. HITL discipline closing [1].
|
||||
|
||||
#### Slide 5 — The boundary keeps us honest
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **The boundary keeps us honest**
|
||||
>
|
||||
> *Nova stays where it belongs.*
|
||||
>
|
||||
> **In Nova's lane**
|
||||
> → Infrastructure primitives: S3, RDS, Lambda, ECS, DynamoDB, KMS,
|
||||
> CloudFront.
|
||||
> → Operational guardrails: confidence, policy, attestation, audit
|
||||
> lineage.
|
||||
> → CVE response at the infrastructure layer.
|
||||
>
|
||||
> **Outside Nova's lane**
|
||||
> → Application business logic.
|
||||
> → IDE, sprint, author workflows [1].
|
||||
> → Application-layer security: AppSec, dependency review, runtime
|
||||
> threat modeling.
|
||||
> → VM, bare-metal, OS lifecycles [1].
|
||||
>
|
||||
> *The line is the contract. Everything below the contract is Nova.
|
||||
> Everything above it stays where it has always been.*
|
||||
|
||||
**Speaker notes (~250 words):** Architecture boundary discipline.
|
||||
AppSec stays with app team as autonomy-preserving design choice.
|
||||
Boundary as operating principle, not defensive posture [1].
|
||||
|
||||
#### Slide 6 — The 18-month shape
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **The 18-month shape**
|
||||
>
|
||||
> *Where CDLC meets SDLC + PDLC — through the contract surface, not
|
||||
> above it.*
|
||||
>
|
||||
> **α (now → Q4'26) — Operating model + federated governance.** A
|
||||
> named platform-ops body owns the platform; SLAs on every L2 are
|
||||
> ratifiable by platform + consumer. The operating model is
|
||||
> published; integration surfaces for SDLC and PDLC harnesses are
|
||||
> documented at the contract boundary.
|
||||
>
|
||||
> **β (Q1'27) — Auto-published infra observability.** Every consumer
|
||||
> stack ships with CloudWatch dashboards, uptime-kuma monitors, and
|
||||
> alert routing on apply — infrastructure primitives publish
|
||||
> observability as a property, no per-team authoring required.
|
||||
>
|
||||
> **γ (Q2'27) — Runbook generation from telemetry.** Every L1
|
||||
> primitive ships with an auto-generated incident runbook derived
|
||||
> from observed patterns. SREs get a starting runbook, not a blank
|
||||
> page.
|
||||
>
|
||||
> **δ (Q3'27 → Q4'27) — Audit ledger, tamper-resistant + externally
|
||||
> addressable.** The SQLite hash-evidence stream migrates to S3
|
||||
> Object Lock + JWS signatures. External counsel verifies any
|
||||
> production change back to a named human attestation.
|
||||
>
|
||||
> *Nova absorbs no IDE, no editor, no sprint tool, no agent harness.*
|
||||
|
||||
**Speaker notes (~250 words):** Boundary-respecting integration
|
||||
argument. α as unlock + governance discipline [1]. β's infra-vs-app
|
||||
observability discipline [1]. γ's infra-vs-app runbook discipline
|
||||
[1]. δ as audit lineage outward, not upstream [1].
|
||||
|
||||
#### Slide 7 — What we ask · What comes back
|
||||
|
||||
**On-slide body:**
|
||||
|
||||
> **What we ask · What comes back**
|
||||
>
|
||||
> **What we ask.**
|
||||
> Architecture endorsement. Runway to the next milestone.
|
||||
>
|
||||
> **Why now.**
|
||||
> Agentic SDLC is reshaping the delivery curve. What is barely
|
||||
> keepable today — incident response, compliance reconciliation,
|
||||
> security remediation — does not compress at the same rate as the
|
||||
> velocity it has to keep pace with. By the end of 2027, the gap
|
||||
> between delivery acceleration and operational absorption is the
|
||||
> structural risk.
|
||||
>
|
||||
> **What comes back.**
|
||||
> The infrastructure foundation that absorbs the velocity. Metrics
|
||||
> that tell us where to push next. Audit lineage that closes the
|
||||
> regulatory question. The next milestone, **by November 2026**.
|
||||
>
|
||||
> *What we do not ask for: an IDE, a sprint tool, an author workflow,
|
||||
> an upstream pipeline. Nova stays in its lane [1].*
|
||||
|
||||
**Speaker notes (~256 words):** Opens with "This is presented to
|
||||
Infrastructure & Operations leadership in August 2026." Asks for
|
||||
architecture endorsement and runway to next milestone by November
|
||||
2026. Velocity framing with **60% goal as internal directional
|
||||
target, not sourced claim**. Closes with "Use the runway to land the
|
||||
architecture endorsement."
|
||||
|
||||
## v1.31 — Leadership Deck Polish II (active milestone)
|
||||
|
||||
> **NFR/refinement milestone — polish pass on the v1.30 leadership
|
||||
> deck.** A single refinement phase that enriches the visible on-slide
|
||||
> prose, improves the slide layout, and re-renders the PPTX — without
|
||||
> altering the S&P visual theme, the 7-slide structure, the speaker-note
|
||||
> word-count bands, the vision `[1]` grounding, or the diagram PNGs.
|
||||
> The v1.30 deck shipped with sparse visible wording (slides 2–6
|
||||
> averaged 43–67 visible words, leaning on diagrams); v1.31 makes each
|
||||
> slide stand on its own as a readable artifact while a presenter
|
||||
> speaks, so the deck reads cleanly both live and as a leave-behind.
|
||||
>
|
||||
> This is a **refinement-only** milestone (no new features, no new
|
||||
> slides, no theme change, no schema change). Tags run on the
|
||||
> **v1.30.x** line (previous minor): `v1.30.0` (P0) → `v1.30.1` (P1
|
||||
> execution = milestone release). Milestone branch:
|
||||
> `milestone/v1.31-leadership-deck-polish`. Authoritative resume
|
||||
> state: `.ciagent/CHECKPOINT.json`.
|
||||
>
|
||||
> **Source spec:** REQ-373 v1.0 (locked 2026-08-20). Full requirement
|
||||
> text in `.ciagent/REQUIREMENTS.md` §v1.31.
|
||||
|
||||
### v1.31 ID allocations (no collisions with shipped history)
|
||||
|
||||
- **Decisions:** `D-247` (1 decision, authored in CLARIFY).
|
||||
- **D-247** — Polish is **refinement-only and theme-preserving**.
|
||||
The S&P theme tokens (`#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`),
|
||||
the 7-slide count, the per-slide speaker-note word-count bands
|
||||
(1/2/4/6: 150–300; 3/5: 250–400; 7: 200–300), the vision `[1]`
|
||||
citations on slides 3/5/7, the 7 mermaid diagram PNGs, and the
|
||||
footer string are all **invariants** of this milestone — they must
|
||||
not change. Polish is confined to: (a) denser, better-structured
|
||||
visible body prose on each slide, (b) layout improvement within
|
||||
the existing `render_pptx.py` block vocabulary (lead/quote/plain/
|
||||
bullet/ordered/image/table/benefit), and (c) re-rendering the
|
||||
PPTX. No new slides, no new diagrams, no theme tokens, no new
|
||||
speaker-note bands. The citizen-developer deck
|
||||
(`nova-autonomous-cloud-delivery-marp.md`) remains untouched
|
||||
(D-241 still holds).
|
||||
- **Capabilities:** none new (CAP-042 is refined in place; no new CAP
|
||||
allocated). Next free CAP remains CAP-043.
|
||||
|
||||
### v1.31 Scope (CLARIFY-grounded, full autonomy)
|
||||
|
||||
Refinement of `docs/presentations/nova-leadership-deck-marp.md`:
|
||||
|
||||
- Enrich the **visible on-slide prose** on every slide so the deck
|
||||
reads as a standalone artifact (current slides 2–6 average 43–67
|
||||
visible words; target a denser, well-structured body that does not
|
||||
crowd the diagram or overflow the slide).
|
||||
- Improve **slide layout** using the existing renderer's block
|
||||
vocabulary — re-balance the order of lead/quote/plain/benefit
|
||||
blocks so each slide has a clear title, a framing line, the body,
|
||||
the diagram, and a closing italic benefit line where present.
|
||||
- Preserve all invariants (D-247): theme tokens, slide count = 7,
|
||||
speaker-note bands, `[1]` citations on 3/5/7, the 7 diagram PNGs,
|
||||
the footer string.
|
||||
- Re-render `docs/presentations/nova-leadership-deck.pptx` via
|
||||
`scripts/render_pptx.py`; the smoke test
|
||||
`scripts/check_leadership_deck.sh` must still exit 0.
|
||||
|
||||
### v1.31 Hard constraints (invariants — D-247)
|
||||
|
||||
- Slide count stays exactly 7.
|
||||
- S&P theme tokens are the only colors.
|
||||
- Speaker-note word counts stay in band per REQ-372.4 bands.
|
||||
- `[1]` citations remain present in slides 3, 5, 7 speaker notes.
|
||||
- The 7 diagram PNGs (`assets/png/leadership-slide-N.png`) are reused
|
||||
unchanged; `.mmd` sources are not modified.
|
||||
- Footer string `Nova Platform - Infrastructure & Operations` unchanged.
|
||||
- `scripts/render_pptx.py` is not modified (no new renderer features
|
||||
needed — polish uses the existing block vocabulary).
|
||||
- The citizen-developer deck is not touched.
|
||||
|
||||
### v1.31 Requirements
|
||||
|
||||
Full text in `.ciagent/REQUIREMENTS.md` §v1.31. Summary:
|
||||
|
||||
- **REQ-373.1** — Visible prose density: every slide's on-slide body
|
||||
(excluding speaker notes + images) is enriched to a richer, well-
|
||||
structured wording that reads as a standalone artifact.
|
||||
- **REQ-373.2** — Layout improvement: each slide uses the renderer's
|
||||
block vocabulary to balance title → frame → body → diagram → benefit.
|
||||
- **REQ-373.3** — Invariants preserved (D-247): theme, 7 slides,
|
||||
note bands, `[1]` citations, diagram PNGs, footer.
|
||||
- **REQ-373.4** — PPTX re-rendered; smoke test exits 0.
|
||||
|
||||
### v1.31 phase status (live — tag `v1.30.2` = the milestone release)
|
||||
|
||||
| Phase | Status | Tag |
|
||||
|-------|--------|-----|
|
||||
| P0 pre-execution | complete | v1.30.1 |
|
||||
| P1 polish | complete | v1.30.2 (milestone release) |
|
||||
@@ -299,4 +299,822 @@
|
||||
|
||||
Full v1.26 requirement text:
|
||||
`.ciagent/nova-blockchain-exchange/REQUIREMENTS.md`. Active phase plan:
|
||||
`.ciagent/PLAN.md`.
|
||||
`.ciagent/PLAN.md`.
|
||||
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (complete, tag `v1.27.6`, merged to main 2026-08-19)
|
||||
|
||||
> **Feature milestone — complete.** The Nova CLI is installable from
|
||||
> internal PyPI (CodeArtifact); every `core/` module is reachable as a
|
||||
> `nova <subcommand>`; the CLI and Lambda functions share a single
|
||||
> `core/` source tree; and Nova owns its identity layer end-to-end
|
||||
> (Nova-idp: `nova-idp-auth` + `nova-idp-token-vend` Lambdas, KMS-signed
|
||||
> OIDC tokens, kyverno-json ABAC token vending, PAT lifecycle). No
|
||||
> AWS-managed identity services in the path.
|
||||
>
|
||||
> Tags run on the **v1.27.x** line: `v1.27.0` (P0) →
|
||||
> `v1.27.1..v1.27.N` → `v1.27.(N+1)` (final = milestone release).
|
||||
> Milestone branch: `milestone/v1.28-cli-identity`.
|
||||
>
|
||||
> **ID re-mapping (no collisions):** the source spec used `REQ-001..031`,
|
||||
> `CAP-025..030`, `INV-63/64/65/18..21/34`, `D-NEW-26/37..41`, and a `kj`
|
||||
> engine — none of which exist in this repo (CAP-025..032 and
|
||||
> INV-1..11 are already allocated to blockchain/pilot work; the policy
|
||||
> engine is kyverno-json, not `kj`). This file uses the re-mapped IDs:
|
||||
> `REQ-323..353`, `CAP-033..038`, `INV-12..17`, `D-226..231`. The 1:1
|
||||
> mapping is recorded in CLARIFY.md. Decisions D-226..D-231 are authored
|
||||
> in CLARIFY (full autonomy) — they are not pre-existing "locked inputs".
|
||||
|
||||
### Decisions (locked in CLARIFY — full autonomy, load-bearing for v1.28)
|
||||
|
||||
- **D-226 (Mode resolution priority):** flag → env (`NOVA_CLIENT_MODE`) →
|
||||
credential type → TTY heuristic. Invalid env values are ignored + warned,
|
||||
falling through to credential type. No silent fallbacks (NFR-1).
|
||||
- **D-227 (ABAC engine = kyverno-json):** the token-vend Lambda uses the
|
||||
existing kyverno-json engine (INV-4 swappable) as the ABAC evaluator,
|
||||
not a new `kj` engine. Policy at `platform/abac/token-vend.policy`.
|
||||
- **D-228 (Argon2id in Lambda):** `argon2-cffi` with bundled wheels; if
|
||||
the C extension fails to load, fall back to the pure-Python
|
||||
implementation; if both fail, document the Fargate migration path.
|
||||
- **D-229 (PAT revocation SLO):** strongly-consistent DynamoDB read on
|
||||
every token-vend request; revocation takes effect within 60s P95 (NFR-4).
|
||||
- **D-230 (JWKS endpoint):** Lambda function URL behind a custom domain;
|
||||
rate limiting at the DNS/CDN layer. API Gateway migration deferred to
|
||||
v1.19+ if throttling requirements grow.
|
||||
- **D-231 (ABAC policy ownership + versioning):** Platform Security owns
|
||||
`platform/abac/token-vend.policy`; changes require PR review; the
|
||||
policy version (git SHA) is recorded in every token-vend audit event.
|
||||
|
||||
### P1 — CLI Substrate
|
||||
|
||||
#### REQ-323 — CodeArtifact wheel + Lambda layer pipeline
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** Given a merge to `main` affecting `core/`, when CI runs, then both
|
||||
the wheel and the Lambda layer are published to CodeArtifact with
|
||||
identical version strings; if either fails, the merge is rejected.
|
||||
|
||||
#### REQ-324 — CLI subcommand per `core/` module
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** (1) Every module in `core/` has a corresponding `nova/<module>.py`
|
||||
subcommand. (2) Subcommand files are ≤ 50 lines and contain no business
|
||||
logic — they delegate to `core/`. (3) CAP-034 verifies delegation by AST
|
||||
scan.
|
||||
|
||||
#### REQ-325 — `nova init` scaffolds project
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a directory with no `.nova/`, when Dev runs `nova init`,
|
||||
then `.nova/`, `.nova/contract.yml.attestations/`, and `.gitignore`
|
||||
(excluding secrets) are created.
|
||||
|
||||
#### REQ-326 — `nova cli-action` published
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** (1) Action is available on both GitHub and Gitea marketplaces.
|
||||
(2) Integration test verifies byte-identical behavior on both platforms.
|
||||
(3) Python 3.12 is pinned.
|
||||
|
||||
#### REQ-327 — `mode_resolver.py` priority
|
||||
**Journeys:** J2, J3. **Priority:** High.
|
||||
**AC:** (1) Explicit `--mode=agent|interactive` flag always wins.
|
||||
(2) Otherwise `NOVA_CLIENT_MODE` env var. (3) Otherwise credential type
|
||||
default. (4) Otherwise TTY heuristic. (5) Property tests cover all four
|
||||
levels. (6) INV-13 (mode determinism) enforced at PR time.
|
||||
|
||||
#### REQ-328 — Audit emission with mode + selection_reason
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** Given any CLI invocation, when the CLI runs, then the emitted
|
||||
`cli.invocation` audit event contains `mode`, `selection_reason`,
|
||||
`credential_type`, `command`, and `args`. INV-12 (mode observability)
|
||||
enforced.
|
||||
|
||||
### P2 — Lambda Packaging + Identity Layer
|
||||
|
||||
#### REQ-329 — Dual-use Lambda/CLI import
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given `core/lambda/contract_ingestor.py`, when imported from the
|
||||
Lambda handler, then it executes the Lambda path; when imported from the
|
||||
CLI, then it executes the local path; and the two paths share ≥ 80% of
|
||||
their code.
|
||||
|
||||
#### REQ-330 — Local env synthesizer
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a contract and a `--local` flag, when `nova apply --local`
|
||||
runs, then a local env is synthesized via `core/env.py:get_env()` without
|
||||
provisioning cloud resources.
|
||||
|
||||
#### REQ-331 — Attestations directory scaffolded
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given `nova init` ran, when Dev lists
|
||||
`.nova/contract.yml.attestations/`, then the directory exists and is empty.
|
||||
|
||||
#### REQ-332 — JWS signing key from PAT
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a PAT, when Dev runs `nova apply --local --sign-local-review`,
|
||||
then a JWS attestation is produced; the JWS is HMAC-SHA256 with a key
|
||||
derived from the PAT via `HKDF-SHA256(PAT_bytes, salt='nova-local-attestation',
|
||||
info='jws-signing-key')` → 32-byte symmetric key (C-5.2 grill fix). The
|
||||
verification key is derived from the PAT via the same KDF (the PAT is
|
||||
the shared secret). INV-14..17 (attestation invariants) enforced.
|
||||
|
||||
#### REQ-333 — `nova-idp-auth` Lambda
|
||||
**Journeys:** J1, J2. **Priority:** High.
|
||||
**AC:** (1) Lambda exposes sign-up, sign-in, and session creation
|
||||
endpoints. (2) Passwords are hashed with Argon2id. (3) Sessions are
|
||||
stored in DynamoDB. (4) CAP-036 verifies end-to-end auth flow.
|
||||
|
||||
#### REQ-334 — Argon2id password hashing
|
||||
**Journeys:** J1, J2. **Priority:** High.
|
||||
**AC:** Given a sign-up request, when the user record is persisted, then
|
||||
the password is stored as an Argon2id hash; raw passwords never appear in
|
||||
logs, traces, environment variables, or DynamoDB records.
|
||||
|
||||
#### REQ-335 — DynamoDB tables for identity
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** (1) Tables exist: `nova-users`, `nova-sessions`,
|
||||
`nova-password-resets`. (2) Tables are provisioned by `nova idp setup`.
|
||||
(3) Point-in-time recovery is enabled on each.
|
||||
|
||||
#### REQ-336 — `nova-idp-token-vend` Lambda
|
||||
**Journeys:** J1, J2, J4. **Priority:** High.
|
||||
**AC:** (1) Lambda accepts a PAT (or session token) and returns a
|
||||
KMS-signed OIDC token. (2) Token claims include `sub`, `aud`, `iss`,
|
||||
`exp`, and role claims. (3) ABAC policy is evaluated before signing.
|
||||
|
||||
#### REQ-337 — KMS-signed OIDC tokens
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** (1) Signing key is a KMS asymmetric key (RSA or ECDSA).
|
||||
(2) Token signature is verifiable via the JWKS endpoint. (3) KMS
|
||||
round-trip test passes. CAP-037 verifies.
|
||||
|
||||
#### REQ-338 — JWKS endpoint as Lambda function URL
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** Given the identity stack is deployed, when a client GETs the JWKS
|
||||
URL, then the public key(s) for token verification are returned with
|
||||
`Content-Type: application/json`.
|
||||
|
||||
#### REQ-339 — kyverno-json ABAC policy file
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** (1) Policy at `platform/abac/token-vend.policy`. (2) Policy inputs
|
||||
include subject, requested claims, target resource, and environment.
|
||||
(3) kyverno-json `evaluate` returns allow/deny; the decision is emitted to
|
||||
the audit stream.
|
||||
|
||||
#### REQ-340 — `nova idp setup` walks admin
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** (1) Command supports `--check`, `--apply`, and `--verify` modes.
|
||||
(2) `--check` reports missing prerequisites and the required IAM policy.
|
||||
(3) `--apply` generates a CloudFormation template and requires explicit
|
||||
approval. (4) `--verify` runs the KMS round-trip test.
|
||||
|
||||
#### REQ-341 — CloudFormation template for review
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** Given `nova idp setup --apply`, when the template is generated,
|
||||
then the template is presented for review; resources are not created until
|
||||
the operator approves; `--dry-run` shows the resource list without writing.
|
||||
|
||||
#### REQ-342 — PAT issuance via portal
|
||||
**Journeys:** J4. **Priority:** High.
|
||||
**AC:** (1) PAT is a signed JWT. (2) PAT hash is stored in DynamoDB.
|
||||
(3) PAT includes a unique `jti` and an expiry claim. (4) Revocation marks
|
||||
the `jti` as revoked.
|
||||
|
||||
#### REQ-343 — PAT hashes in DynamoDB
|
||||
**Journeys:** J4. **Priority:** High.
|
||||
**AC:** (1) Only the hash (not the raw PAT) is stored. (2) Table supports
|
||||
lookup-by-hash and lookup-by-`jti`. (3) Revoked PATs are retained for
|
||||
audit, not deleted.
|
||||
|
||||
#### REQ-344 — `nova auth` commands
|
||||
**Journeys:** J2, J4. **Priority:** High.
|
||||
**AC:** (1) `nova auth login` exchanges session → OIDC token, stores
|
||||
locally. (2) `nova auth revoke --pat <id>` marks a PAT revoked.
|
||||
(3) `nova auth status` shows current credential, mode, and
|
||||
selection_reason. (4) All commands emit audit events.
|
||||
|
||||
### P3 — Documentation
|
||||
|
||||
#### REQ-345 — Operator guide for `nova idp setup`
|
||||
**Priority:** High.
|
||||
**AC:** Guide published covering `--check`, `--apply`, `--verify`,
|
||||
prerequisite IAM policy, and the CloudFormation review flow.
|
||||
|
||||
#### REQ-346 — Developer guide for `nova auth login`
|
||||
**Priority:** High.
|
||||
**AC:** Guide published covering signup, signin, login, mode resolution,
|
||||
and credential-type behavior at a TTY vs. piped stdout.
|
||||
|
||||
#### REQ-347 — Identity-layer threat model
|
||||
**Priority:** High.
|
||||
**AC:** Threat model published covering Argon2id storage, KMS signing,
|
||||
JWKS exposure, PAT revocation SLO, ABAC token vending, and the no-AWS-
|
||||
managed-identity constraint (NFR-5).
|
||||
|
||||
### P4 — Integration Testing
|
||||
|
||||
#### REQ-348 — E2E integration test
|
||||
**Priority:** High.
|
||||
**AC:** Given a deployed Nova-idp, when the test runs, then sign-up →
|
||||
sign-in → token-vend → apply → audit completes successfully; the audit
|
||||
event chain is verifiable.
|
||||
|
||||
#### REQ-349 — Property tests for `mode_resolver`
|
||||
**Priority:** High.
|
||||
**AC:** (1) Property tests cover all four priority levels. (2) Edge cases:
|
||||
TTY but piped stdout, missing credential, conflicting flag/env, invalid
|
||||
env value. (3) INV-13 enforced via test.
|
||||
|
||||
#### REQ-350 — KMS round-trip test
|
||||
**Priority:** High.
|
||||
**AC:** Given a token signed by the token-vend Lambda, when the test
|
||||
fetches the JWKS and verifies the signature, then verification succeeds.
|
||||
|
||||
#### REQ-351 — PAT revocation SLO test
|
||||
**Priority:** High.
|
||||
**AC:** Issue PAT → use to vend token → revoke → assert denial within 60s
|
||||
P95. Test passes in CI.
|
||||
|
||||
### P5 — Capability Gate
|
||||
|
||||
#### REQ-352 — CAP-033..038 gate rules wired into CI
|
||||
**Priority:** High.
|
||||
**AC:** (1) CAP-033 (CLI subcommand surface exists): `nova --help` lists a
|
||||
subcommand for every `core/` module. (2) CAP-034 (subcommand delegates to
|
||||
`core/`): every `nova/<module>.py` ≤ 50 lines, no business logic, AST
|
||||
scan. (3) CAP-035 (layer matches wheel): Lambda layer ARN version matches
|
||||
the `nova-cli` wheel version. (4) CAP-036 (Nova-idp auth flow works): E2E
|
||||
test (REQ-348) passes. (5) CAP-037 (token-vend signs via KMS): KMS
|
||||
round-trip (REQ-350) passes. (6) CAP-038 (PAT issuance + revocation):
|
||||
REQ-351 passes. Failure of any → merge blocked.
|
||||
|
||||
#### REQ-353 — Capability gate GREEN for v1.28 release
|
||||
**Priority:** High.
|
||||
**AC:** CAP-001..CAP-032 remain Verified; CAP-033..CAP-038 are Verified.
|
||||
All v1.28 release-gate criteria in PLAN.md §6 met.
|
||||
|
||||
### v1.28 Invariants (new — INV-12..INV-17)
|
||||
|
||||
- **INV-12 (Mode observability):** Every CLI invocation emits a
|
||||
`cli.invocation` audit event containing `mode`, `selection_reason`,
|
||||
`credential_type`, `command`, and `args`.
|
||||
- **INV-13 (Mode resolution determinism):** Resolution priority is
|
||||
flag → env (`NOVA_CLIENT_MODE`) → credential type → TTY. No silent
|
||||
fallbacks. Deviations rejected at PR time.
|
||||
- **INV-14 (Credential type encodes role):** `developer_pat` /
|
||||
`nova_oidc_token` + TTY present → `interactive`; TTY absent → `agent`.
|
||||
- **INV-15 (No AWS-managed identity in path):** Nova-idp MUST NOT depend
|
||||
on Cognito, IAM Identity Center, or any AWS-managed identity service.
|
||||
- **INV-16 (Password storage):** Passwords hashed with Argon2id; raw
|
||||
passwords never in logs/traces/env/DynamoDB.
|
||||
- **INV-17 (ABAC discipline):** The token-vend Lambda evaluates the
|
||||
kyverno-json ABAC policy before signing; allow/deny + policy inputs
|
||||
emitted to the audit stream.
|
||||
|
||||
### v1.28 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-323 | P1 | complete (v1.27.1) |
|
||||
| REQ-324 | P1 | complete (v1.27.1) |
|
||||
| REQ-325 | P1 | complete (v1.27.1) |
|
||||
| REQ-326 | P1 | complete (v1.27.1) |
|
||||
| REQ-327 | P1 | complete (v1.27.1) |
|
||||
| REQ-328 | P1 | complete (v1.27.1) |
|
||||
| REQ-329 | P2 | complete (v1.27.2) |
|
||||
| REQ-330 | P2 | complete (v1.27.2) |
|
||||
| REQ-331 | P2 | complete (v1.27.2) |
|
||||
| REQ-332 | P2 | complete (v1.27.2) |
|
||||
| REQ-333 | P3 | complete (v1.27.3) |
|
||||
| REQ-334 | P3 | complete (v1.27.3) |
|
||||
| REQ-335 | P3 | complete (v1.27.3) |
|
||||
| REQ-336 | P4 | complete (v1.27.4) |
|
||||
| REQ-337 | P4 | complete (v1.27.4) |
|
||||
| REQ-338 | P4 | complete (v1.27.4) |
|
||||
| REQ-339 | P4 | complete (v1.27.4) |
|
||||
| REQ-340 | P4 | complete (v1.27.4) |
|
||||
| REQ-341 | P4 | complete (v1.27.4) |
|
||||
| REQ-342 | P4 | complete (v1.27.4) |
|
||||
| REQ-343 | P4 | complete (v1.27.4) |
|
||||
| REQ-344 | P4 | complete (v1.27.4) |
|
||||
| REQ-345 | P5 | complete (v1.27.5) |
|
||||
| REQ-346 | P5 | complete (v1.27.5) |
|
||||
| REQ-347 | P5 | complete (v1.27.5) |
|
||||
| REQ-348 | P5 | complete (v1.27.5) |
|
||||
| REQ-349 | P5 | complete (v1.27.5) |
|
||||
| REQ-350 | P5 | complete (v1.27.5) |
|
||||
| REQ-351 | P5 | complete (v1.27.5) |
|
||||
| REQ-352 | P6 | complete (v1.27.6) |
|
||||
| REQ-353 | P6 | complete (v1.27.6) |
|
||||
|
||||
---
|
||||
|
||||
## v1.29 — Reposplit + Identity Layer Bring-Live (complete, tag `v1.28.6`, merged to main 2026-08-20)
|
||||
|
||||
> **Feature milestone — complete.** v1.29 extracts all live platform
|
||||
> components into a dedicated Gitea-private Terraform repository
|
||||
> (`nova-platform-ops`), brings Nova-idp live in account `581513795199`
|
||||
> for the first time, and standardizes `acdl/acdl` on GitHub. `kj` (a
|
||||
> compiled Go binary, pinned v0.0.3, distinct from the kyverno-json
|
||||
> engine) has exactly one identity: one ECR image digest shared by the
|
||||
> production Lambda runtime and its defensive Fargate fallback
|
||||
> (KJ-LOCKSTEP, REQ-371).
|
||||
>
|
||||
> Tags run on the **v1.28.x** line: `v1.28.0` (P0) →
|
||||
> `v1.28.1..v1.28.5` (execution) → `v1.28.6` (final = milestone release).
|
||||
> Milestone branch: `milestone/v1.29-reposplit-identity`.
|
||||
>
|
||||
> **Scope split (CLARIFY-grounded, full autonomy):** Terraform module
|
||||
> code is authored out-of-band in `nova-platform-ops`. REQs marked
|
||||
> `[covered-reference]` have their verification surface in the
|
||||
> `nova-platform-ops` cutover gates (M1/M1.5/M2), documented in the
|
||||
> operator guide (`docs/operator-guide-platform-ops.md`). CIAgent in
|
||||
> `acdl` authors only the acdl-side REQs.
|
||||
|
||||
### Decisions (locked in CLARIFY — full autonomy, load-bearing for v1.29)
|
||||
|
||||
- **D-232 (Forge parity abandoned):** the byte-identical-forges CI parity
|
||||
(Gitea + GitHub) is abandoned; `acdl/acdl` standardizes on GitHub. CI
|
||||
fails with `forge_parity_disabled` (deliberate). Rationale: Vision §4
|
||||
domain boundaries — operations lives in Gitea-private `nova-platform-
|
||||
ops`, engineering lives on GitHub.
|
||||
- **D-233 (JWKS public-read via CloudFront edge):** the JWKS endpoint is
|
||||
the only public read surface of the live platform (INV-18). All other
|
||||
platform endpoints gate with `AuthType: AWS_IAM`. CloudFront + OAC
|
||||
pinning replaces direct Lambda Function URL exposure.
|
||||
- **D-234 (KMS asymmetric key provisioning):** `alias/nova-oidc-signing`
|
||||
provisioned with `KeySpec: ECC_NIST_P256`, `KeyUsage: SIGN_VERIFY`,
|
||||
90-day rotation cadence (matches per-stack CMK rotation per D-069).
|
||||
- **D-235 (Tag-pin handoff):** engineering hands off to operations via
|
||||
tags. `acdl/acdl` `publish.yml` attaches artifacts to GitHub Releases
|
||||
per tag; `nova-platform-ops` declares `local.nova_platform_version` +
|
||||
`local.kj_source_sha` and resolves substrates through a single
|
||||
`data.aws_ecr_image.kj_image`.
|
||||
- **D-236 (Cutover shape + rollback procedure):** M1 day-0 cutover is
|
||||
conditional on M1.5 verification gate (3 consecutive rebuilds, 12-item
|
||||
spike per grill CF-1). Rollback = revert `nova_platform_version` pin;
|
||||
the prior tag's artifacts remain downloadable. M2a (Fargate toggle)
|
||||
activates only if M1.5 fails 3×.
|
||||
- **D-237 (Fargate sunset discipline):** the always-warm minimal Fargate
|
||||
standby (REQ-363b, ~$15–20/month) may not be deleted unless REQ-363 has
|
||||
been green in production for ≥30 consecutive days. Sunset requires an
|
||||
architecture review.
|
||||
- **D-238 (KJ-LOCKSTEP release-gate invariant):** the ECR image digest
|
||||
running on the Fargate standby MUST equal the digest resolved by
|
||||
`aws_lambda_function.nova_idp_token_vend.image_uri` at every
|
||||
`terraform plan`. Enforced by `lifecycle.precondition` (mechanism) +
|
||||
Gitea Actions `if: steps.plan.outcome == 'success'` (mechanism) + PR
|
||||
comment reporting (observability) + operator review (last, never
|
||||
first). No second pipeline, no second SHA pin. Vision §6 immutability
|
||||
+ Vision §5 narrow interfaces.
|
||||
|
||||
### P1 — Publish Pipeline
|
||||
|
||||
#### REQ-354 — `publish.yml` attaches Lambda zip + layer wheel + Python wheel + ECR container image to GitHub Release for each tag
|
||||
**Journeys:** J1, J2 (criteria 3–4). **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given a tag `v1.29.x` is pushed to `acdl/acdl` main, when
|
||||
`publish.yml` runs, then the release artifacts `nova-lambda-token-vend-
|
||||
v1.29.x.zip`, `nova-cli-layer-v1.29.x.zip`, and `nova-1.29.x-py3-none-
|
||||
any.whl` appear in GitHub Releases with matching SHA-256 in the body.
|
||||
**(2)** Given two consecutive tags `v1.29.0` and `v1.29.1`, when both
|
||||
releases are queried, then each tag's artifacts are independent and the
|
||||
previous tag's artifacts remain downloadable.
|
||||
**(3)** Given the publish pipeline runs for tag `v1.29.x`, when the
|
||||
image build step executes, then a single ECR image is pushed at tag
|
||||
`v1.29.x-kj-<kj-source-sha>` where `<kj-source-sha>` is read from
|
||||
`platform/abac/kj-version.txt` at build time and embedded in the tag
|
||||
(D-239: ECR tags reject `+`; corrected from `v1.29.x+kj-<sha>` to
|
||||
`v1.29.x-kj-<sha>`).
|
||||
**(4)** Given the image is pushed, when the GitHub Release body lists
|
||||
artifacts, then the image URI and digest appear alongside the wheel,
|
||||
layer, and Lambda zip. KJ-STATIC: the `kj` binary is compiled
|
||||
`CGO_ENABLED=0 GOOS=linux GOARCH=amd64` and `file(1)` reports
|
||||
`statically linked, no shared library` before embedding.
|
||||
|
||||
### P2 — Gitea Scrub + Decisions
|
||||
|
||||
#### REQ-367 — Hard scrub of all Gitea references in `acdl/acdl` at v1.29.0
|
||||
**Journeys:** Cross-cutting. **Priority:** Critical.
|
||||
**AC:**
|
||||
**(1)** Given v1.29.0 is cut from main, when `grep -rni gitea .github/
|
||||
docs/ pyproject.toml README.md .ciagent/` runs, then zero matches
|
||||
outside this spec's archive section.
|
||||
**(2)** Given v1.29.0 ships, when `.gitea/` is checked in the working
|
||||
tree, then `find .gitea` returns nothing.
|
||||
**(3)** Given v1.29.0 ships, when the bit-identical-forges parity is
|
||||
asserted in CI, then CI fails with `forge_parity_disabled` (deliberate;
|
||||
documented in D-232).
|
||||
|
||||
#### REQ-368 — Decisions D-232..238 recorded in PROJECT.md + CLARIFY
|
||||
**Journeys:** Cross-cutting. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given the milestone is recorded, when loading `PROJECT.md`, then
|
||||
decisions D-232 (forge parity abandoned), D-233 (JWKS public-read via
|
||||
CloudFront edge), D-234 (KMS asymmetric key provisioning), D-235 (tag-
|
||||
pin handoff), D-236 (cutover shape + rollback procedure), D-237
|
||||
(Fargate sunset discipline ≥30 days → architecture review), D-238
|
||||
(KJ-LOCKSTEP release-gate invariant) are present with rationale citing
|
||||
Vision §4 domain boundaries.
|
||||
**(2)** Given decisions are present, then each decision references the
|
||||
source statement from the v1.29 spec.
|
||||
|
||||
### P3 — CFN Archive + TF Delegation
|
||||
|
||||
#### REQ-369 — CFN → Terraform conversion of `nova idp setup`
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given the CFN template in `acdl/acdl/nova/idp/setup.py`, when
|
||||
the equivalent Terraform in `nova-platform-ops` runs, then the same
|
||||
resources (Lambdas, DDB tables, IAM roles, KMS key references) are
|
||||
created. [covered-reference: nova-platform-ops]
|
||||
**(2)** Given the conversion, when a new operator runs `nova idp setup
|
||||
--apply`, then the CLI delegates to `terraform apply`; the CFN code
|
||||
path is no longer the active path.
|
||||
**(3)** Given the conversion, the CFN file in `acdl/acdl` is archived
|
||||
to `docs/archive/nova-idp-cfn-v1.28.md` as read-only reference;
|
||||
deletion is a follow-up.
|
||||
|
||||
### P4 — Operator Guide + Reference Tracking (docs)
|
||||
|
||||
#### REQ-OPS-GUIDE — `docs/operator-guide-platform-ops.md`
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given the operator guide is published, when an operator reads
|
||||
it, then it covers: KMS rotation (90-day cadence, `alias/nova-oidc-
|
||||
signing`), JWKS reachability via CloudFront edge (OAC pinning, public
|
||||
read vs. IAM-gated), PITR restore (DynamoDB point-in-time recovery),
|
||||
PAT revocation (60s SLO), edge configuration (CloudFront + WAF + ACM +
|
||||
Route53), Fargate standby status checks (`GET /health` every 10s,
|
||||
`KJ-WARMUP-HEALTH`), cost section (WAF ~$5–10/month + Fargate
|
||||
~$15–20/month), artifact-mirror fallback (operator-local mirror by
|
||||
SHA-256 when Gitea `act_runner` cannot reach GitHub Releases), and the
|
||||
M1/M1.5/M2 cutover gates as release-gate entries for the covered-
|
||||
reference REQs.
|
||||
|
||||
### P5 — Consumer Deploy Bump (cross-project, Edge 8)
|
||||
|
||||
#### REQ-CONSUMER-BUMP — `nova-blockchain-exchange` deploy.yml `@v1.25` → `@v1.29`
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given `nova-blockchain-exchange` deploy.yml pins
|
||||
`acdl/.github/workflows/deploy.yml@v1.25`, when the bump is applied,
|
||||
then both `.github/workflows/deploy.yml` and
|
||||
`.gitea/workflows/deploy.yml` reference `@v1.29`.
|
||||
**(2)** Given the bump, when the smoke test runs (sign-up → sign-in →
|
||||
token-vend → apply → audit), then the chain completes successfully
|
||||
against the v1.29 publish artifacts.
|
||||
|
||||
### Covered-reference requirements (authored in `nova-platform-ops`, out-of-band)
|
||||
|
||||
The following REQs are tracked for milestone completeness but their
|
||||
code lands in `nova-platform-ops`. Their verification surface is the
|
||||
M1/M1.5/M2 cutover gates documented in the operator guide.
|
||||
|
||||
- **REQ-355** — ops repo pins `local.nova_platform_version` +
|
||||
`local.kj_source_sha`; CI resolves matching artifacts + image digest.
|
||||
- **REQ-356** — ops repo CI runs `terraform plan` on every PR; drift
|
||||
fails with `drift_detected`.
|
||||
- **REQ-357** — HITL approver distinct from PR author required for
|
||||
`terraform apply` (INV-3, TFM-HITL).
|
||||
- **REQ-358** — Operator bumps `nova_platform_version` to roll out
|
||||
engineering change; `CodeSha256` matches the artifact SHA-256.
|
||||
- **REQ-359** — ops repo is Gitea-private with no GitHub mirror
|
||||
(OPER-PRIV).
|
||||
- **REQ-360** — ops repo IAM scope is bounded; no AdministratorAccess
|
||||
(IAM-NARROW).
|
||||
- **REQ-361** — Terraform imports existing live resources idempotently
|
||||
(IMPORT-IDEMPOTENT).
|
||||
- **REQ-362** — `alias/nova-oidc-signing` KMS key provisioned
|
||||
(`ECC_NIST_P256`, `SIGN_VERIFY`, 90-day rotation).
|
||||
- **REQ-363** — Nova-idp 3 Lambdas deployed on container image with
|
||||
static `kj` (production substrate, KJ-STATIC).
|
||||
- **REQ-363b** — Fargate defensive fallback — always-warm minimal
|
||||
Fargate standby, **same ECR image** (KJ-LOCKSTEP, KJ-WARMUP-HEALTH).
|
||||
- **REQ-364** — JWKS Function URL reachable only via CloudFront with
|
||||
OAC pinning (INV-18, JWKS-EDGE-ONLY).
|
||||
- **REQ-365** — WAF WebACL rate-limit (3000/5min) + AWS Managed Rules.
|
||||
- **REQ-366** — ACM cert + Route53 alias for the JWKS domain.
|
||||
- **REQ-371** — KJ-LOCKSTEP applied-at-plan mechanism
|
||||
(`lifecycle.precondition` on both image-bearing resources; fail-closed
|
||||
by mechanism, not by discipline).
|
||||
|
||||
### v1.29 Invariants + NFR constraints (new)
|
||||
|
||||
- **INV-18 (JWKS-EDGE-ONLY):** the JWKS endpoint is the only public read
|
||||
surface of the live platform. All other platform endpoints MUST gate
|
||||
with `AuthType: AWS_IAM`.
|
||||
- **KJ-STATIC (NFR):** `kj` compiled `CGO_ENABLED=0`; `file(1)` reports
|
||||
`statically linked, no shared library`; SHA-256 matches
|
||||
`platform/abac/kj-version.txt`; recorded in Terraform state.
|
||||
- **KJ-LOCKSTEP (NFR):** Fargate standby digest == Lambda `image_uri`
|
||||
digest at every `terraform plan`. Detected by
|
||||
`lifecycle.precondition` (mechanism) + CI `if:
|
||||
steps.plan.outcome == 'success'` (mechanism) + PR comment
|
||||
(observability) + operator review (last). No second pipeline, no
|
||||
second SHA pin.
|
||||
- **KJ-WARMUP-HEALTH (NFR):** Fargate standby `READY` probe (`GET /health
|
||||
→ 200` every 10s) green before M1 cutover; release-gate entry.
|
||||
- **OPER-PRIV (NFR):** `nova-platform-ops` `private: true`, not mirrored.
|
||||
- **IAM-NARROW (NFR):** Gitea OIDC role bounded per REQ-360; no
|
||||
`Action: "*"` or `Resource: "*"`.
|
||||
- **DRIFT-DETECT (NFR):** `terraform plan` exit 2 (drift) fails the
|
||||
apply workflow; manual reconciliation required.
|
||||
- **IMPORT-IDEMPOTENT (NFR):** re-import exits non-zero with
|
||||
`resource_already_imported`.
|
||||
- **TFM-HITL (NFR):** `terraform apply` against `main` requires Gitea
|
||||
Actions approval from a user distinct from the PR author.
|
||||
- **JWKS-SLO (NFR):** `GET /.well-known/jwks.json` P95 < 200ms same-
|
||||
region; `Cache-Control: max-age=3600` honored.
|
||||
- **JWKS-ROTATION (NFR):** on key rotation, both old + new public keys
|
||||
published during 24-hour overlap window.
|
||||
|
||||
### v1.29 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-354 | P1 | complete (v1.28.1) |
|
||||
| REQ-367 | P2 | complete (v1.28.2) |
|
||||
| REQ-368 | P2 | complete (v1.28.2) |
|
||||
| REQ-369 | P3 | complete (v1.28.3) |
|
||||
| REQ-OPS-GUIDE | P4 | complete (v1.28.4) |
|
||||
| REQ-CONSUMER-BUMP | P5 | complete (v1.28.5) |
|
||||
| REQ-355 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-356 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-357 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-358 | covered-reference | planned (M2 gate: nova-platform-ops) |
|
||||
| REQ-359 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-360 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-361 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-362 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-363 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-363b | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-364 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-365 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-366 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-371 | covered-reference | planned (M2 gate: nova-platform-ops) |
|
||||
|
||||
> **Covered-reference REQs** are verified via the M1/M1.5/M2 cutover
|
||||
> gates in `nova-platform-ops` CI (out-of-band). The operator attests
|
||||
> the results in `docs/operator-guide-platform-ops.md` §18 "Cutover
|
||||
> Gates" Result column. P6 audit verifies the template + Result column
|
||||
> exist; the live-green attestation is out-of-band (grill CF-1/CF-2).
|
||||
|
||||
## v1.30 — Single-shot Leadership Deck (active milestone)
|
||||
|
||||
> **Feature milestone — single-shot PPTX leadership deck.** Ships
|
||||
> REQ-372.1 through REQ-372.12 in one execution phase. Tags run on the
|
||||
> **v1.29.x** line (milestone v1.30 → tags v1.29.1..v1.29.3). Tag
|
||||
> `v1.29.3` = the milestone release. The deck is a discrete artifact,
|
||||
> hand-authored (NOT a compression of the existing citizen-developer
|
||||
> pitch per D-241), scoped to a single live presentation to
|
||||
> Infrastructure & Operations leadership in August 2026, securing
|
||||
> architecture endorsement and a November 2026 runway.
|
||||
>
|
||||
> Source: `docs/presentations/nova-leadership-deck-marp.md` (authored
|
||||
> against the Slide Content Map in `.ciagent/PROJECT.md` §v1.30 spec).
|
||||
> Rendered via the existing `scripts/render_pptx.py` (narrowly extended
|
||||
> per D-242 to accept an explicit source path + custom output filename
|
||||
> and to add a per-slide footer textbox). Smoke test:
|
||||
> `scripts/check_leadership_deck.sh` (runnable on demand; NOT a CI gate
|
||||
> per the single-shot constraint). Vision grounding `[1]` citations
|
||||
> resolve to `docs/vision.md` (the spec's `acdl-vision.md` reference).
|
||||
|
||||
### Decisions (locked in CLARIFY, full autonomy — load-bearing for v1.30)
|
||||
|
||||
- **D-241 (Q3 override):** 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.
|
||||
Rationale: the spec §2.2 + cover note forbid compression/mirroring;
|
||||
the Slide Content Map is hand-authored content, not derived.
|
||||
- **D-242 (render pipeline):** The existing `scripts/render_pptx.py`
|
||||
is narrowly extended to (a) accept an explicit source `.md` path +
|
||||
custom output `.pptx` filename (the cover note's invocation
|
||||
`scripts/render_pptx.py docs/presentations/nova-leadership-deck.md`
|
||||
is honoured via a path-aware argv), and (b) render a right-aligned
|
||||
footer textbox on every slide with the exact string
|
||||
`Nova Platform - Infrastructure & Operations` (the python-pptx
|
||||
renderer does not read the Marp `footer:` directive; REQ-372.5
|
||||
requires the footer on every rendered slide). This extension is a
|
||||
non-REQ-372 prerequisite per spec §3.3 Edge 2 ("scope narrowly and
|
||||
update `render_pptx.py` separately"). The source file 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.
|
||||
- **D-243 (date anchor):** August 2026 is a month-only presentation
|
||||
anchor (no specific day); November 2026 is the runway anchor
|
||||
(~90 days). Slide 7 references "Infrastructure & Operations
|
||||
leadership" without naming a specific day. Resolves spec §7 Q1.
|
||||
|
||||
### Requirements
|
||||
|
||||
#### REQ-372.1 — Source markdown exists and is parseable
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the deck initiative is scoped, **when**
|
||||
`docs/presentations/nova-leadership-deck-marp.md` is read, **then** the
|
||||
file exists, parses as valid Marp markdown, contains exactly 7 slides
|
||||
delimited by `---`, and the file header carries the related-artifacts
|
||||
comment (per REQ-372.9).
|
||||
|
||||
#### REQ-372.2 — PPTX render via existing pipeline
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the source markdown exists (REQ-372.1), **when**
|
||||
`scripts/render_pptx.py` is invoked against the leadership deck source,
|
||||
**then** `docs/presentations/nova-leadership-deck.pptx` is written with
|
||||
7 slides and python-pptx raised no exceptions.
|
||||
|
||||
#### REQ-372.3 — Slide count is exactly 7
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the source markdown, **when** slide boundaries are counted,
|
||||
**then** the count equals 7.
|
||||
|
||||
#### REQ-372.4 — Speaker notes depth per slide
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the source markdown, **when** speaker notes (HTML comments)
|
||||
are extracted per slide, **then** per-slide word counts fall within:
|
||||
slides 1/2/4/6 in 150–300; slides 3/5 in 250–400; slide 7 in 200–300.
|
||||
Smoke test exits non-zero on violation.
|
||||
|
||||
#### REQ-372.5 — Footer on every slide
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the source markdown's Marp frontmatter `footer:` directive +
|
||||
the python-pptx renderer extension (D-242), **when** the PPTX is
|
||||
rendered, **then** every slide carries the right-aligned footer
|
||||
`Nova Platform - Infrastructure & Operations`.
|
||||
|
||||
#### REQ-372.6 — S&P theme tokens are the only colors used
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the source markdown, **when** color values are extracted
|
||||
(Marp directives + inline overrides), **then** the only hex colors
|
||||
present are `#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`.
|
||||
|
||||
#### REQ-372.7 — Slide-by-slide content traceability
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the rendered PPTX, **when** any slide N ∈ [1, 7] is opened,
|
||||
**then** its content matches the **Slide Content Map** in
|
||||
`.ciagent/PROJECT.md` §v1.30 spec. Any deviation from the map requires
|
||||
`CLARIFY` before ship. Smoke test does not assert content strings
|
||||
verbatim (brittle); audit verifies by visual review against the map.
|
||||
|
||||
#### REQ-372.8 — Smoke test exits 0 on pass
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** `scripts/check_leadership_deck.sh` exists, **when** invoked
|
||||
from the repo root, **then** the script asserts: (a) source file
|
||||
exists, (b) slide count = 7, (c) per-slide word counts in band, (d)
|
||||
footer string present in source, (e) only S&P hex colors used, (f)
|
||||
PPTX file exists. Exits 0 on pass, non-zero on fail. Runnable on
|
||||
demand; not wired as a CI gate.
|
||||
|
||||
#### REQ-372.9 — Related-artifacts comment in source header
|
||||
|
||||
**Priority:** Med · **Journey:** J1
|
||||
|
||||
**Given** the source markdown, **when** the file header is inspected,
|
||||
**then** a comment exists that (i) names this deck as the leadership
|
||||
artifact for Infrastructure & Operations, (ii) names August 2026 as
|
||||
the presentation date, (iii) names
|
||||
`nova-autonomous-cloud-delivery-marp.md` as a related-but-distinct
|
||||
artifact and notes that this deck does not compress or modify it.
|
||||
|
||||
#### REQ-372.10 — CAP-042 appended to STATE.md at ship
|
||||
|
||||
**Priority:** Med · **Journey:** J1
|
||||
|
||||
**Given** the deck has shipped, **when** STATE.md is updated at the
|
||||
v1.30 milestone ship wave, **then** a CAP-042 row exists capturing
|
||||
artifact paths (`nova-leadership-deck-marp.md`,
|
||||
`nova-leadership-deck.pptx`), audience (Infrastructure & Operations
|
||||
leadership), single-shot intent, presentation month (August 2026).
|
||||
|
||||
#### REQ-372.11 — D-241 recorded in PROJECT.md at ship
|
||||
|
||||
**Priority:** Med · **Journey:** J1
|
||||
|
||||
**Given** the deck has shipped, **when** PROJECT.md is updated at the
|
||||
v1.30 milestone ship wave, **then** a `D-241` entry exists capturing:
|
||||
(a) single-shot nature of the deck, (b) audience (Infrastructure &
|
||||
Operations leadership), (c) August 2026 anchor + November 2026 runway,
|
||||
(d) explicit decision not to compress the existing citizen-developer
|
||||
deck.
|
||||
|
||||
#### REQ-372.12 — Vision grounding citations in architecture-load slides
|
||||
|
||||
**Priority:** Med · **Journey:** J1
|
||||
|
||||
**Given** the source markdown, **when** the speaker notes are
|
||||
inspected, **then** at least one `[1]` citation appears in slides 3,
|
||||
5, and 7 — the three architecture-load slides — grounding the
|
||||
principles, anti-goals, and integration-boundary claims to
|
||||
`docs/vision.md` (the spec's `acdl-vision.md` reference [1]).
|
||||
|
||||
### v1.30 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-372.1 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.2 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.3 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.4 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.5 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.6 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.7 | P1/P3 | complete (v1.29.5, polished + diagrams) |
|
||||
| REQ-372.8 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.9 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.10 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.11 | P1/P3 | complete (v1.29.5, polished) |
|
||||
| REQ-372.12 | P1/P3 | complete (v1.29.5, polished) |
|
||||
|
||||
## v1.31 — Leadership Deck Polish II (active milestone)
|
||||
|
||||
> **Refinement-only NFR milestone — polish pass on the v1.30 leadership
|
||||
> deck.** Enriches visible on-slide prose + improves layout, then
|
||||
> re-renders the PPTX. No new features, no new slides, no theme change,
|
||||
> no diagram change (D-247). Tags run on the **v1.30.x** line (previous
|
||||
> minor): `v1.30.0` (P0) → `v1.30.1` (P1 = milestone release). The
|
||||
> v1.30 requirements (REQ-372.1..12) remain complete and are NOT
|
||||
> re-opened; v1.31 adds REQ-373.1..4 as a refinement layer over the
|
||||
> same artifact.
|
||||
|
||||
### Decisions (locked in CLARIFY, full autonomy — load-bearing for v1.31)
|
||||
|
||||
- **D-247 (refinement-only, theme-preserving):** The polish is
|
||||
confined to visible prose density + layout. The following are
|
||||
**invariants** and must not change: the S&P theme tokens
|
||||
(`#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`); the 7-slide count; the
|
||||
per-slide speaker-note word-count bands (1/2/4/6: 150–300; 3/5:
|
||||
250–400; 7: 200–300, per REQ-372.4); the `[1]` citations on slides
|
||||
3/5/7 (per REQ-372.12); the 7 mermaid diagram PNGs and their `.mmd`
|
||||
sources; the footer string `Nova Platform - Infrastructure &
|
||||
Operations`. No new slides, no new diagrams, no renderer changes.
|
||||
The citizen-developer deck is untouched (D-241 still holds).
|
||||
|
||||
### Requirements
|
||||
|
||||
#### REQ-373.1 — Visible prose density enriched per slide
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the v1.30 deck shipped with sparse visible wording (slides
|
||||
2–6 averaged 43–67 visible words, leaning on diagrams), **when** the
|
||||
polished source markdown is inspected, **then** every slide's on-slide
|
||||
body (excluding speaker-note HTML comments and image references)
|
||||
carries richer, well-structured wording that lets the slide read as a
|
||||
standalone artifact — a title, a framing line, a body, and (where
|
||||
present) a closing italic benefit — without crowding the diagram or
|
||||
overflowing the 16:9 slide. Verified by visual review against the
|
||||
Slide Content Map and by the smoke test still passing.
|
||||
|
||||
#### REQ-373.2 — Layout improved within the renderer block vocabulary
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the existing `scripts/render_pptx.py` block vocabulary
|
||||
(lead/quote/plain/bullet/ordered/image/table/benefit) is not modified,
|
||||
**when** the polished PPTX is rendered, **then** each slide balances
|
||||
its blocks so the reading order is clear (title → frame → body →
|
||||
diagram → benefit) and the diagram remains the visual anchor. No new
|
||||
renderer features are added.
|
||||
|
||||
#### REQ-373.3 — v1.30 invariants preserved (D-247)
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the D-247 invariants, **when** the polished source + PPTX
|
||||
are validated, **then**: slide count = 7; the only hex colors are the
|
||||
four S&P tokens; per-slide speaker-note word counts remain in band per
|
||||
REQ-372.4; `[1]` citations remain in slides 3, 5, 7 speaker notes; the
|
||||
7 diagram PNGs are reused unchanged; the footer string is unchanged.
|
||||
The smoke test enforces (a)–(f) and must exit 0.
|
||||
|
||||
#### REQ-373.4 — PPTX re-rendered; smoke test exits 0
|
||||
|
||||
**Priority:** High · **Journey:** J1
|
||||
|
||||
**Given** the polished source markdown, **when**
|
||||
`scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md
|
||||
--output docs/presentations/nova-leadership-deck.pptx` is invoked,
|
||||
**then** the PPTX is written with 7 slides and python-pptx raises no
|
||||
exceptions, **and** `bash scripts/check_leadership_deck.sh` exits 0.
|
||||
|
||||
### v1.31 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-373.1 | P1 | complete (v1.30.2) |
|
||||
| REQ-373.2 | P1 | complete (v1.30.2) |
|
||||
| REQ-373.3 | P1 | complete (v1.30.2) |
|
||||
| REQ-373.4 | P1 | complete (v1.30.2) |
|
||||
@@ -1,144 +0,0 @@
|
||||
# Nova — v1.27 Research Findings
|
||||
|
||||
> Phase: research (pre-execution). Milestone: v1.27 (PO State Catalog &
|
||||
> Ciagent Compression). Status: research. Researcher: ci-researcher.
|
||||
> Autonomy: full.
|
||||
|
||||
v1.27 is an NFR milestone (docs/chore only, no code, no schema). There
|
||||
is no new domain to research. The research is a codebase-grounded
|
||||
inventory of (a) the files to archive + their staleness evidence, and
|
||||
(b) the sources backing the STATE.md capability backfill. This file
|
||||
records the inventory for the v1.27 record; the active authoring used
|
||||
these sources directly.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files to archive (staleness inventory)
|
||||
|
||||
### 1.1 Pre-execution artifacts (v1.26 — shipped, decisions folded)
|
||||
|
||||
| File | Lines | Staleness evidence | Decisions folded into |
|
||||
|---|---|---|---|
|
||||
| `CLARIFY.md` | 225 | v1.26 milestone shipped (`v1.25.5`); decisions D-200..D-213 | `PROJECT.md` load-bearing decisions |
|
||||
| `GRILL.md` | 225 | v1.26 grill verdict PROCEED 0.84; binding revisions applied | `PLAN.md` revisions (G-Q4 REQ-322→P2 W0; G-Q6 enforcement deferred; G-Q9 key-split future) |
|
||||
| `IDEATE.md` | 193 | all 7 accepted ideas → REQ-315..322 (shipped) | `REQUIREMENTS.md` v1.26 traceability |
|
||||
| `RESEARCH.md` | 250 | v1.26 domain research (blockchain, deploy, modules, metrics) | `ARCHITECTURE.md` §12.8; shipped REQs |
|
||||
|
||||
All four are pre-execution artifacts for a shipped milestone. The next
|
||||
P0 writes fresh versions. Per D-219 (user-confirmed) + D-222: archive
|
||||
all four with `-v1.26` suffixes.
|
||||
|
||||
### 1.2 Phase verifications + review (v1.26 — shipped, PASS)
|
||||
|
||||
| File | Lines | Staleness evidence |
|
||||
|---|---|---|
|
||||
| `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.md` | 46 | v1.26 live apply evidence (`blkex-pilot-apply-v0.2`); summarized in `nova-blockchain-exchange/README.md` §5 + REVIEW-AUDIT-P05 §2.2 |
|
||||
|
||||
### 1.3 Durable references (superseded or stale)
|
||||
|
||||
| File | Lines | Staleness evidence | Superseded by |
|
||||
|---|---|---|---|
|
||||
| `CAPABILITY_INVENTORY.md` | 120 | dated 2026-07-27; framed as "v1.1→v1.8 re-verification sweep"; predates v1.26 pilot (CAP-025 absent; blockchain capabilities absent) | `STATE.md` (this milestone) |
|
||||
| `AUTONOMY_THESIS.md` | 65 | "Last refined: v1.21"; thesis fully folded into `NORTH_STAR.md` Vision (lines 17–22) + Anti-Goals #2 | `NORTH_STAR.md` |
|
||||
| `COST.md` | 106 | dated 2026-07-29; framed "v1.0 → v1.14"; predates v1.26 live pilot (ECS + ALB + DynamoDB + S3 costs not reflected) | A future cost milestone writes a fresh report; `STATE.md` Domain 7 notes cost tracking as a capability |
|
||||
|
||||
### 1.4 Consumer-side (nova-blockchain-exchange)
|
||||
|
||||
| File | Lines | Staleness evidence |
|
||||
|---|---|---|
|
||||
| `nova-blockchain-exchange/ROADMAP.md` | 57 | marks P3/P4/P5 as "planned" but v1.26 shipped (`v1.25.5`); phase narrative preserved in platform `ROADMAP.md` v1.26 section |
|
||||
|
||||
Per D-221: consumer archives land in
|
||||
`.ciagent/nova-blockchain-exchange/archive/ROADMAP-v1.26.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Files to keep active (no-edit or fix-only)
|
||||
|
||||
### 2.1 No-edit (live code paths or durable)
|
||||
|
||||
| File | Why keep active |
|
||||
|---|---|
|
||||
| `CHECKPOINT.json` | Authoritative resume state — never archive |
|
||||
| `config.json` | Operational config — never archive |
|
||||
| `REGRESSION_REPORT.json` | Written by `core/regression_verify.py:705`; read by `core/metrics/collector.py:27` + `trust_snapshot.py:21` + metrics views (D-224: regenerates on next `run_regression.sh`) |
|
||||
| `REGRESSION_REPORT.md` | Written by `core/regression_verify.py:704`; read by `scripts/run_regression.sh` (D-224) |
|
||||
| `PERSONAS.md` | Regenerated at each milestone P0 by the lead-developer; not stale until then |
|
||||
| `IAM_POLICY.md` | Live baseline, test-enforced (`tests/test_iam_policy_baseline.py`); D-207 future key-split pending (D-223) |
|
||||
| `PLAN.md` | Active phase plan; reset to next milestone at next P0 |
|
||||
| `ARCHITECTURE.md` | Durable target architecture (§1–§12 + §12.7 + §12.8 + §12.9) |
|
||||
| `NORTH_STAR.md` | PO strategy; loaded every ci-run via `config.strategic_direction_file` |
|
||||
| `nova-blockchain-exchange/PROJECT.md` | Consumer project charter; D-200..D-205 load-bearing |
|
||||
| `nova-blockchain-exchange/REQUIREMENTS.md` | REQ-310..322 spec intent (shipped but spec stays for reference) |
|
||||
| `nova-blockchain-exchange/README.md` | Consumer onboarding guide; still accurate (deploy workflow, secrets, contract shape, verification) |
|
||||
|
||||
### 2.2 Fix-only (corrections to stale-but-kept files)
|
||||
|
||||
| File | Fix |
|
||||
|---|---|
|
||||
| `PROJECT.md` | v1.26 phase-status block (lines 424–431): P3/P4/P5 "pending" → "complete" with shipped tags `v1.25.3/4/5`; add STATE.md pointer (D-225: P2 phase) |
|
||||
| `ROADMAP.md` | v1.26 P3/P4/P5 sections (lines 238, 261, 272) "planned" → "complete" with shipped tags; v1.26 Overview line "active" → "complete"; add STATE.md to P5 ship-update list (D-225: P2 phase) |
|
||||
| `archive/README.md` | Add the 11 new archived files to the contents tables (P2 phase) |
|
||||
|
||||
---
|
||||
|
||||
## 3. STATE.md capability backfill sources
|
||||
|
||||
The STATE.md backfill (36 capabilities across 10 domains) was sourced
|
||||
from:
|
||||
|
||||
| Source | Used for |
|
||||
|---|---|
|
||||
| `core/regression_verify.py` (lines 129–768) | CAP-001..CAP-025 IDs, names, tiers, evidence pointers |
|
||||
| `.ciagent/CAPABILITY_INVENTORY.md` (pre-archive) | CAP-001..022 descriptions, defect notes, evidence |
|
||||
| `modules/registry.json` | L1/L2 module catalog (13 L1 + 2 L2 entries) |
|
||||
| `.ciagent/REQUIREMENTS.md` v1.25 traceability | REQ-291..309 → policy-engine capabilities |
|
||||
| `.ciagent/nova-blockchain-exchange/REQUIREMENTS.md` + `.ciagent/REQUIREMENTS.md` v1.26 traceability | REQ-310..322 → pilot capabilities |
|
||||
| `.ciagent/CHECKPOINT.json` | shipped tags `v1.25.0..v1.25.5` |
|
||||
| `git log --all --oneline` | file paths for v1.26 shipped features |
|
||||
| `.ciagent/PROJECT.md` load-bearing decisions | INV-1..INV-11 invariants |
|
||||
| `docs/submission-readiness.md` + `schemas/contract.schema.json` | INV-1 contract surface |
|
||||
|
||||
---
|
||||
|
||||
## 4. Persona assessment
|
||||
|
||||
v1.27 is a docs/chore milestone. The active roster:
|
||||
|
||||
- **lead-developer** (active): owns the milestone narrative (STATE.md
|
||||
authoring, PROJECT/ROADMAP fixes, archive README, PLAN/NORTH_STAR
|
||||
wiring, this RESEARCH, CLARIFY, PLAN, final review + audit). Territory:
|
||||
`.ciagent/`, `docs/`.
|
||||
- **backend-engineer** (active, limited): no code changes in v1.27.
|
||||
Consulted on the `core/confidence_signal.py` LSP diagnostic (pre-
|
||||
existing, not touched by v1.27). No territory writes.
|
||||
- **data-engineer** (inactive): no schema/migration/ORM changes.
|
||||
- **policy-engineer** (inactive): no policy authoring.
|
||||
- **frontend-engineer** (inactive): no UI.
|
||||
- **blockchain-engineer** (inactive): no chain code.
|
||||
|
||||
Territory enforcement: warn. The milestone is `.ciagent/`-only; the
|
||||
lead-developer owns all writes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk analysis
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Archive move breaks a relative path reference in an active file | Low | Medium | `grep` for the archived filenames across `.ciagent/` + `docs/` before commit; fix any dangling references in P2 |
|
||||
| STATE.md capability row is inaccurate (wrong shipped tag / wrong file path) | Medium | Low | The backfill sources are the authoritative registries (regression_verify.py, registry.json, CHECKPOINT.json, git log); citations are direct |
|
||||
| PROJECT.md phase-status fix conflicts with a future v1.26-era commit | Low | Low | v1.26 is shipped (main has the milestone merge); no v1.26-era commits will arrive |
|
||||
| REGRESSION_REPORT stale state is mistaken for v1.27 scope | Low | Low | D-224 records the decision; STATE.md Domain 7 notes the current CAP range |
|
||||
|
||||
---
|
||||
|
||||
## 6. Verdict
|
||||
|
||||
v1.27 is feasible, scoped, and the sources are grounded. No new domain,
|
||||
no new code, no schema breaks. The archive moves are lossless (git
|
||||
history + archive directory both preserve bytes). The STATE.md
|
||||
backfill is sourced from authoritative registries. Proceed to PLAN.
|
||||
@@ -81,6 +81,108 @@
|
||||
before building the new env). New `core/env_transition.py` module.
|
||||
15 requirements (REQ-276..290), 4 phases.
|
||||
|
||||
- **v1.27:** complete (tag `v1.26.3`) — PO State Catalog & Ciagent
|
||||
Compression. NFR milestone. Authored `.ciagent/STATE.md` (PO-facing
|
||||
capability catalog, 32 CAP rows + 11 invariants across 10 domains,
|
||||
backfilled through v1.26). Archived 7 platform-root files + 1
|
||||
consumer file to `.ciagent/archive/` (CAPABILITY_INVENTORY,
|
||||
REVIEW-AUDIT-P05, VERIFY-P03, VERIFY-P04, P4-PILOT-RUN-EVIDENCE,
|
||||
AUTONOMY_THESIS, COST + nova-blockchain-exchange/ROADMAP). Fixed
|
||||
PROJECT.md + ROADMAP.md v1.26 phase-status (P3/P4/P5 → complete).
|
||||
Wired STATE.md into the P-final ship discipline (PLAN.md, ROADMAP.md,
|
||||
NORTH_STAR.md). Active `.ciagent/` root: 15 .md (was 25) + 1 json + 1
|
||||
checkpoint. 3 phases (P0 pre-execution + P1 author-archive + P2
|
||||
fix-stale-wire + P3 final-review-ship). No REQ-NNN (NFR).
|
||||
|
||||
- **v1.28:** complete (tag `v1.27.6`) — CLI Canonicalization + Identity
|
||||
Layer. Feature milestone. The Nova CLI is installable from internal
|
||||
PyPI (CodeArtifact); every `core/` module is reachable as a `nova
|
||||
<subcommand>` (15 subcommands, argparse-only, ≤50-line thin delegates,
|
||||
CAP-033/034); `nova init` scaffolds `.nova/`; `nova cli-action`
|
||||
composite action published to GitHub + Gitea (byte-identical, NFR-11);
|
||||
`core/mode_resolver.py` (flag → env → credential type →
|
||||
`sys.stdin.isatty()`, D-226); INV-12 audit emission on every
|
||||
invocation. Nova owns its identity layer end-to-end (Nova-idp):
|
||||
`nova-idp-auth` Lambda (sign-up/sign-in/session, Argon2id t=3 m=65536
|
||||
p=1, fail-closed D-228), `nova-idp-token-vend` Lambda (KMS-signed
|
||||
OIDC tokens, ECDSA P-256 / ES256, DER→raw conversion, kyverno-json
|
||||
ABAC fail-closed C-6.1), `nova-idp-jwks` Lambda (JWKS endpoint),
|
||||
`nova-pats` DynamoDB (PAT lifecycle, strong-read revocation D-229,
|
||||
60s SLO), `nova auth login/revoke/status`, `nova idp setup
|
||||
--check/--apply/--verify` (CloudFormation, NFR-10 explicit approval).
|
||||
No AWS-managed identity (INV-15). 31 requirements (REQ-323..353), 6
|
||||
capabilities (CAP-033..038), 6 invariants (INV-12..17), 6 decisions
|
||||
(D-226..231). 6 execution phases (P1 cli-substrate, P2
|
||||
lambda-packaging, P3 idp-auth, P4 token-vend-pat + idp-setup folded
|
||||
C-2.1, P5 docs-integration, P6 final-review-ship). Grill:
|
||||
PROCEED-WITH-CONDITIONS (0.76), 3 critical fixes (ABAC fail-closed,
|
||||
JWS KDF, traceability drift) + 16 tracked conditions applied. 1000
|
||||
tests passing. Tags: `v1.27.0` (P0) → `v1.27.1..v1.27.5` (P1..P5) →
|
||||
`v1.27.6` (P6 final = milestone release).
|
||||
|
||||
- **v1.29 (complete, tag `v1.28.6` = the v1.29 release, merged to main
|
||||
2026-08-20):** Reposplit + Identity Layer Bring-Live. Feature milestone.
|
||||
v1.29 extracts all live platform components (Nova-idp Lambdas, KMS keys,
|
||||
DynamoDB tables, S3 state buckets, OIDC roles, JWKS, audit outbox
|
||||
bootstrap) from `acdl/acdl` into a dedicated Gitea-private Terraform
|
||||
repository (`nova-platform-ops`), brings Nova-idp live in account
|
||||
`581513795199` for the first time (code complete since v1.28, unverified
|
||||
in-account at Phase 0), and standardizes `acdl/acdl` on GitHub. The
|
||||
split enforces Vision §4 domain boundaries architecturally —
|
||||
engineering ends at the compiled artifact; operations begins at the
|
||||
live platform under guardrails. `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 both
|
||||
the production Lambda runtime and its defensive Fargate fallback
|
||||
(KJ-LOCKSTEP — drift eliminated by construction, enforced by
|
||||
`lifecycle.precondition` at plan time, REQ-371). M1.5 verification gate
|
||||
(8-item spike, 3 consecutive rebuilds) gates M1 cutover. CIAgent in
|
||||
`acdl` delivers the acdl-side work (publish.yml + ECR image, Gitea
|
||||
scrub, CFN archive + CLI terraform-delegation, operator guide,
|
||||
consumer deploy bump); the Terraform modules for `nova-platform-ops`
|
||||
are authored out-of-band (covered-reference REQs with cutover gates as
|
||||
the verification surface). 17 requirements (REQ-354..369 + 371 +
|
||||
363b), 7 decisions (D-232..238), 1 invariant (INV-18 JWKS-EDGE-ONLY) +
|
||||
10 NFR constraints. Tags: `v1.28.0` (P0) → `v1.28.1..v1.28.5` (P1..P5)
|
||||
→ `v1.28.6` (P6 final = milestone release).
|
||||
|
||||
- **v1.30 (complete, tag `v1.29.5` = the v1.30 release, merged to main
|
||||
2026-08-20):** Single-shot Leadership Deck (polished). Feature
|
||||
milestone. A hand-authored 7-slide PPTX deck for Infrastructure &
|
||||
Operations leadership (CTO + VP Technology + Product Management),
|
||||
presented August 2026, securing architecture endorsement and a
|
||||
November 2026 runway. The deck is a **discrete artifact** (D-241:
|
||||
NOT a compression of the existing citizen-developer pitch
|
||||
`nova-autonomous-cloud-delivery-marp.md`, which remains unmodified).
|
||||
Authored as Marp markdown, rendered via the existing
|
||||
`scripts/render_pptx.py` (narrowly extended per D-242: path arg +
|
||||
`--output` + per-slide footer textbox + leading-comment skip +
|
||||
auto-fit + vertical balance + bullet-marker fix). **Polished in P3**
|
||||
(D-244: slide 1 → black cover; D-245: slide 7 "What works now" block
|
||||
— L1/L2 stacks live at confidence 0.800, next steps: greenfield
|
||||
pilots → promote to prod → SPGE constitutional library → infra
|
||||
layer; D-246: 7 S&P-themed mermaid diagrams, one per slide, rendered
|
||||
via local mmdc with vendored Chromium libs). Smoke test
|
||||
`scripts/check_leadership_deck.sh` (on-demand, NOT a CI gate). Vision
|
||||
`[1]` grounding in slides 3/5/7 speaker notes (resolve to
|
||||
`docs/vision.md`). 12 requirements (REQ-372.1..12), 1 capability
|
||||
(CAP-042), 6 decisions (D-241..D-246). Tags: `v1.29.1` (P0) →
|
||||
`v1.29.2` (P1 first draft) → `v1.29.3` (P2 first-draft final) →
|
||||
`v1.29.4` (P3 polish) → `v1.29.5` (P4 polished final = milestone
|
||||
release).
|
||||
|
||||
- **v1.31 (complete, tag `v1.30.2` = the v1.31 release):** Leadership
|
||||
Deck Polish II — refinement-only NFR milestone. Enriches the v1.30
|
||||
deck's visible on-slide prose and improves slide layout, then
|
||||
re-renders the PPTX. Preserves all v1.30 invariants (D-247): S&P
|
||||
theme tokens, 7-slide count, speaker-note word-count bands, `[1]`
|
||||
citations on slides 3/5/7, the 7 mermaid diagram PNGs, and the
|
||||
footer string. No new slides, no new diagrams, no renderer changes;
|
||||
the citizen-developer deck is untouched. Visible prose density
|
||||
raised (slides 2–6: 43–67 → 97–168 visible words). 4 requirements
|
||||
(REQ-373.1..4), 1 decision (D-247). Tags ran on the v1.30.x line:
|
||||
`v1.30.1` (P0) → `v1.30.2` (P1 = milestone release).
|
||||
|
||||
> **Full v1.0–v1.24 phase detail, wave ordering, success criteria, and
|
||||
> decision cross-references:** `.ciagent/archive/ROADMAP-v1.0-v1.24.md`.
|
||||
|
||||
@@ -173,12 +275,15 @@ final). Tags: `v1.24.0` (P0) → `v1.24.5` (P5 = milestone release).
|
||||
summary; delete all milestone branches.
|
||||
- Updated `REQUIREMENTS.md` (mark REQ-291..309 complete), `ROADMAP.md`
|
||||
(mark v1.25 complete), `NORTH_STAR.md` (note Strategic Objective #2 —
|
||||
provable trust via a replaceable policy-engine substrate).
|
||||
provable trust via a replaceable policy-engine substrate),
|
||||
`STATE.md` (append v1.25 capability rows — note: STATE.md was authored
|
||||
in v1.27 with the v1.25 capabilities backfilled; the v1.25 ship did
|
||||
not update STATE.md because STATE.md did not yet exist).
|
||||
- **Requirements:** REQ-291..309 (19 requirements).
|
||||
|
||||
---
|
||||
|
||||
## v1.26 (active, tag line `v1.25.x`): Live Pilot Estate Activation
|
||||
## v1.26 (complete, tag `v1.25.5` = the v1.26 release, merged to main 2026-08-19): Live Pilot Estate Activation
|
||||
|
||||
`D-096` lifts. The first real consumer estate — a stock exchange on a
|
||||
homegrown Proof-of-Authority blockchain (equities only, single
|
||||
@@ -235,7 +340,7 @@ REQ-315..322), 3 deferred. Adversarial grill: PROCEED 0.84.
|
||||
- Cross-cutting: `v1.25` floating tag → `v1.25.0` (Phase 0 ship) on the
|
||||
platform repo.
|
||||
|
||||
### Phase P3 — pilot-metrics-and-policies (planned, tag v1.25.3)
|
||||
### Phase P3 — pilot-metrics-and-policies (complete, tag v1.25.3)
|
||||
- REQ-315: `adapters/kyverno-json/policies/settlement-finality.json` —
|
||||
kyverno-json policy asserting all matches in the promotion window have
|
||||
committed blocks (securities-specific). Authored + tested in v1.26;
|
||||
@@ -258,7 +363,7 @@ REQ-315..322), 3 deferred. Adversarial grill: PROCEED 0.84.
|
||||
- REQ-320: `adapters/kyverno-json/policies/pilot-readiness/no-placeholder-account.json`
|
||||
— declarative gate preventing apply against a placeholder account.
|
||||
|
||||
### Phase P4 — pilot-run-and-docs (planned, tag v1.25.4)
|
||||
### Phase P4 — pilot-run-and-docs (complete, tag v1.25.4)
|
||||
- REQ-321: `adapters/README.md` (new consumer row) +
|
||||
`docs/METRICS.md` (Post-Pilot metrics grounded note) +
|
||||
`.ciagent/ARCHITECTURE.md` §12.8 (Pilot Estate) +
|
||||
@@ -269,7 +374,7 @@ REQ-315..322), 3 deferred. Adversarial grill: PROCEED 0.84.
|
||||
events land in the Decision Ledger; the regression gate (CAP-025)
|
||||
verifies the round-trip.
|
||||
|
||||
### Phase P5 — final review + audit + milestone ship (Final Phase, planned, tag v1.25.5)
|
||||
### Phase P5 — final review + audit + milestone ship (Final Phase, complete, tag v1.25.5 = the v1.26 release)
|
||||
- Multi-persona code review across P1..P4 (lead-developer, backend-
|
||||
engineer, data-engineer, policy-engineer, blockchain-engineer).
|
||||
Auto-fix P0; flag P1+.
|
||||
@@ -281,7 +386,8 @@ REQ-315..322), 3 deferred. Adversarial grill: PROCEED 0.84.
|
||||
full milestone summary; delete all milestone branches.
|
||||
- Update `REQUIREMENTS.md` (mark REQ-310..322 complete), `ROADMAP.md`
|
||||
(mark v1.26 complete), `NORTH_STAR.md` (note Strategic Objectives #1
|
||||
+ #3 — first real consumer estate; Post-Pilot denominators activated).
|
||||
+ #3 — first real consumer estate; Post-Pilot denominators activated),
|
||||
`STATE.md` (append v1.26 capability rows; bump "Last milestone ship").
|
||||
|
||||
> **Phase task-level breakdown, wave ordering, and persona
|
||||
> assignments:** `.ciagent/PLAN.md` (the active phase plan, retained in
|
||||
|
||||
@@ -11,8 +11,47 @@
|
||||
> *why*, read `NORTH_STAR.md`. For *how*, read `ARCHITECTURE.md`. For
|
||||
> *what was decided*, read `PROJECT.md` load-bearing decisions.
|
||||
>
|
||||
> **Last milestone ship:** v1.26 (`v1.25.5`, 2026-08-19).
|
||||
> **Next update:** at v1.27 ship.
|
||||
> **Last milestone ship:** v1.31 (`v1.30.2`, 2026-08-20) — Leadership
|
||||
> Deck Polish II. Refinement-only NFR milestone: enriched the v1.30
|
||||
> leadership deck's visible on-slide prose (slides 2–6: 43–67 → 97–168
|
||||
> visible words) and improved slide layout (title → frame → body →
|
||||
> diagram → closing italic benefit), then re-rendered the PPTX. All
|
||||
> v1.30 invariants preserved (D-247): S&P theme tokens (`#D6002A`,
|
||||
> `#1B1B1B`, `#FFFFFF`, `#F0F0F0`), 7-slide count, speaker-note word-
|
||||
> count bands, `[1]` citations on slides 3/5/7, the 7 mermaid diagram
|
||||
> PNGs, the footer string. No new slides, no new diagrams, no
|
||||
> renderer changes; the citizen-developer deck untouched. Smoke test
|
||||
> `scripts/check_leadership_deck.sh` PASS. 4 requirements (REQ-
|
||||
> 373.1..4), 1 decision (D-247). Tags ran on the v1.30.x line:
|
||||
> `v1.30.1` (P0) → `v1.30.2` (P1 = milestone release). 7 S&P-themed
|
||||
> mermaid diagrams reused unchanged. 2 Gitea releases: 819 (P0),
|
||||
> 820 (P1 milestone).
|
||||
>
|
||||
> **Previous 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)
|
||||
|
||||
@@ -78,6 +117,53 @@
|
||||
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)
|
||||
|
||||
@@ -91,6 +177,8 @@
|
||||
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)
|
||||
|
||||
@@ -246,6 +334,47 @@
|
||||
| — | 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 |
|
||||
| CAP-042 | Leadership presentation deck (single-shot, polished) | v1.30 / `v1.29.5` | `docs/presentations/nova-leadership-deck-marp.md`, `docs/presentations/nova-leadership-deck.pptx`, `scripts/check_leadership_deck.sh`, `scripts/render_leadership_diagrams.sh`, `docs/presentations/assets/mmd/leadership-slide-{1..7}.mmd`, `docs/presentations/assets/png/leadership-slide-{1..7}.png` | REQ-372.1..REQ-372.12, D-241..D-246 | local | Single-shot 7-slide PPTX deck for Infrastructure & Operations leadership (CTO + VP Technology + Product Management); presented August 2026; November 2026 runway anchor; discrete hand-authored artifact (NOT a compression of the citizen-developer pitch per D-241); rendered via existing `scripts/render_pptx.py` (narrowly extended per D-242); polished in P3 (D-244 cover slide, D-245 slide 7 "What works now" block, D-246 7 S&P-themed mermaid diagrams); smoke test on-demand (NOT a CI gate); vision `[1]` grounding in slides 3/5/7 |
|
||||
|
||||
## Archive pointers
|
||||
|
||||
- **v1.0–v1.24 capability narrative + the 2026-07-27 re-verification sweep:**
|
||||
@@ -281,4 +410,161 @@ wave** (Wave 3 "milestone ship" in `PLAN.md`), alongside
|
||||
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.
|
||||
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-042 — all Verified (32 from v1.0..v1.27 + 6 from v1.28 + 3 from v1.29 covered-reference + 1 from v1.30 single-shot deck). 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). CAP-042 added v1.30 (leadership-deck — single-shot, on-demand smoke test, NOT a CI gate).
|
||||
|
||||
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,323 @@
|
||||
# CLARIFY — v1.30 Single-shot Leadership Deck
|
||||
|
||||
> **Autonomy:** full. Auto-resolution with assumption logging per
|
||||
> `config.autonomy.level: "full"`. No human escalation unless
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
The clarify stage identifies ambiguities in the v1.30 specification
|
||||
(REQ-372 v1.0, locked 2026-08-20) and resolves them at full autonomy.
|
||||
The spec is the user-provided "REQ-372 — Nova Leadership Presentation
|
||||
Deck." Each ambiguity gets a decision ID (D-241+, continuing from
|
||||
v1.29's D-232..D-240), a resolution, a confidence score, and a
|
||||
rationale.
|
||||
|
||||
---
|
||||
|
||||
## Prior-conversation resolutions (already locked, restated for the record)
|
||||
|
||||
These were resolved by the user in the pre-run planning conversation
|
||||
that spawned v1.30. They are load-bearing for v1.30 execution.
|
||||
|
||||
### 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?
|
||||
|
||||
**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).
|
||||
|
||||
### 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?
|
||||
|
||||
**Resolution:** Author the source as
|
||||
`docs/presentations/nova-leadership-deck-marp.md` to fit the existing
|
||||
`-marp.md` pipeline convention. Narrowly extend `render_pptx.py` to
|
||||
accept an explicit source `.md` path + `--output` filename, and to
|
||||
render a right-aligned footer textbox on every slide (python-pptx
|
||||
does not read the Marp `footer:` directive). The output is
|
||||
`nova-leadership-deck.pptx` per spec REQ-372.2. Formalized as D-242.
|
||||
**Confidence:** 1.0 (user-confirmed — "Author source as
|
||||
nova-leadership-deck-marp.md, extend render_pptx.py").
|
||||
**Decision:** D-242.
|
||||
|
||||
### 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?
|
||||
|
||||
**Resolution:** Override the stale intake assumption. The leadership
|
||||
deck is a **discrete, hand-authored artifact** — NOT a compression.
|
||||
The existing citizen-developer deck
|
||||
(`nova-autonomous-cloud-delivery-marp.md`) remains untouched. The
|
||||
spec §2.2 + cover note forbid compression/mirroring; the Slide
|
||||
Content Map is hand-authored content, not derived. Update STATE.md
|
||||
intake assumption 3 to reflect the discrete-artifact decision.
|
||||
Formalized as D-241.
|
||||
**Confidence:** 1.0 (user-confirmed — "Override with spec's
|
||||
discrete-artifact decision").
|
||||
**Decision:** D-241.
|
||||
|
||||
### 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?
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## Open questions from the spec's §7 (auto-resolved at full autonomy)
|
||||
|
||||
### Q1 — Specific meeting date inside August 2026
|
||||
|
||||
**Spec context:** The presentation is in August 2026, but no specific
|
||||
day is named. Slide 7 references "Infrastructure & Operations
|
||||
leadership" without naming a day.
|
||||
|
||||
**Resolution:** Anchor to **month-only** (August 2026). No specific
|
||||
day in the deck text. November 2026 is the runway anchor (~90 days
|
||||
from August 2026).
|
||||
**Confidence:** 0.95. **Impact if wrong:** Very low — the meeting is
|
||||
what it is; the deck text doesn't depend on a specific day.
|
||||
**Decision:** D-243 (date anchor discipline: month-only).
|
||||
|
||||
### Q2 — Explicit non-compression of the existing citizen-developer deck
|
||||
|
||||
**Spec context:** The two decks (leadership + citizen-developer)
|
||||
remain discrete artifacts. The existing 23-slide
|
||||
`nova-autonomous-cloud-delivery-marp.md` is not compressed or
|
||||
modified.
|
||||
|
||||
**Resolution:** Document the discrete-artifact constraint in
|
||||
REQ-372.9 (related-artifacts header comment) + D-241 (this file) +
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Polish-phase decisions (D-244, D-245, D-246 — full autonomy)
|
||||
|
||||
The first draft (v1.29.3) passed all 12 REQs but the rendered PPTX
|
||||
has 7 polish issues (vertical imbalance, text clipping, double bullet
|
||||
markers, no visual variety, near-invisible footer, slide-6 red wall,
|
||||
blockquote grey). The user requested: (1) polish all 7 issues, (2)
|
||||
add a "What works now" emphasis to slide 7, (3) generate mermaid
|
||||
diagrams for every slide. These decisions record the deviations from
|
||||
prior binding decisions.
|
||||
|
||||
### D-244 — Slide 1 becomes a black-background cover (overrides grill G-1)
|
||||
|
||||
**Polish issue #4.** Grill G-1 locked "all 7 slides as white content
|
||||
slides." The user approved making slide 1 a black-background cover
|
||||
(strong opener for a live leadership presentation) while keeping
|
||||
slides 2-7 as white content slides. The on-slide body text from the
|
||||
Slide Content Map is preserved verbatim — only the visual treatment
|
||||
(background + text color) changes. REQ-372.7's "deviation requires
|
||||
CLARIFY" clause applies to *content* deviations; this is a
|
||||
presentation polish, not a content change. The source change: slide
|
||||
1's `## The friction...` → `# The friction...` (H1) + `<!-- _class:
|
||||
title -->` directive, which triggers `render_title_slide` (black bg,
|
||||
red top bar, white text).
|
||||
**Confidence:** 1.0 (user-confirmed).
|
||||
|
||||
### D-245 — Slide 7 "What works now" content addition (deviation from Slide Content Map)
|
||||
|
||||
**User request.** A new "What works now" block is added to slide 7's
|
||||
on-slide body, before the existing "What we ask" block. This is a
|
||||
*content* deviation — the Slide Content Map specifies slide 7 as:
|
||||
What we ask / Why now / What comes back + closer. The new block
|
||||
emphasizes what's proven today (L1/L2 stack deployment works, live
|
||||
apply to the sandbox/dev AWS account, confidence 0.800) and the
|
||||
next-steps arc (ingest greenfield pilot projects → promote from
|
||||
sandbox to production → integrate with the SPGE constitutional
|
||||
library → serve as the infrastructure layer).
|
||||
|
||||
**Factual grounding (verified in RESEARCH):**
|
||||
- 13 L1 primitives + 2 L2 modules in `modules/registry.json` (all at
|
||||
`1.0.0`): alb, cloudfront, dynamodb, ecr, ecs-cluster, ecs-service,
|
||||
iam-role, kms-key, rds, s3, uptime, vpc, waf + microservice, static-
|
||||
assets.
|
||||
- CAP-031: live `terraform apply` against AWS account `581513795199`
|
||||
(the dev environment, `mode: full`, dev-only per D-209), producing
|
||||
real ARNs (ALB, ECS, DynamoDB, S3, KMS) at confidence 0.800. This is
|
||||
a **live AWS apply**, NOT "tested locally" and NOT a "sandbox"
|
||||
account (the codebase has no sandbox account; the pilot-readiness
|
||||
policy D-208 blocks placeholder accounts).
|
||||
|
||||
**"sandbox" colloquial term note:** The user explicitly chose to keep
|
||||
"sandbox" as a colloquial term for the dev AWS account after I flagged
|
||||
that the codebase fact is "dev account `581513795199`, live apply."
|
||||
The slide uses "sandbox" (colloquial); the speaker notes carry the
|
||||
precise facts (dev account, live apply, confidence 0.800).
|
||||
|
||||
**"SPGE constitutional library" unverified-in-repo note:** "SPGE"
|
||||
and "constitutional library" appear nowhere in the codebase (0
|
||||
matches across all `.md`/`.py`/`.json`/`.yml`). The user confirmed it
|
||||
is an external system the audience recognizes. The slide includes it
|
||||
as a named next-step integration target, not as a current capability.
|
||||
The speaker notes do not assert it exists in the codebase.
|
||||
|
||||
The Slide Content Map in PROJECT.md is updated to reflect the new
|
||||
slide 7 structure.
|
||||
**Confidence:** 0.90.
|
||||
|
||||
### D-246 — Mermaid diagrams on all 7 slides
|
||||
|
||||
**User request.** Each of the 7 slides gets a leader-friendly,
|
||||
non-technical mermaid diagram covering its core concept. Different
|
||||
mermaid shapes per slide for visual variety.
|
||||
|
||||
**Render path (verified):** The local `mmdc` (mermaid-cli) is blocked
|
||||
by missing Chromium shared libraries (10+ system libs absent, no
|
||||
root). Resolution: 24 Debian bookworm `.deb` packages fetched from the
|
||||
Debian mirror, `.so` files extracted to `/home/opencode/tmp/so2/`,
|
||||
`LD_LIBRARY_PATH` set to include them. Chromium (puppeteer cache at
|
||||
`/home/opencode/.cache/puppeteer/chrome/.../chrome`) launches with
|
||||
the vendored libs; `mmdc` renders with the S&P theme (`sp-theme.json`)
|
||||
+ 2x scale + transparent background. Verified: test diagram renders
|
||||
to a valid PNG with all 4 S&P colors present. The `.mmd` source files
|
||||
+ rendered PNGs are committed for reproducibility; a future
|
||||
environment with system Chromium can re-render via the existing
|
||||
`scripts/render_slides.sh` mermaid step.
|
||||
|
||||
**Theme:** Inline `%%{init: {...}}%%` directive in each `.mmd` with
|
||||
S&P theme variables (`primaryColor:#1B1B1B`, `primaryBorderColor:
|
||||
#D6002A`, `primaryTextColor:#fff`, `secondaryColor:#fff`,
|
||||
`tertiaryColor:#F0F0F0`, `lineColor:#1B1B1B`) — only the 4 S&P tokens.
|
||||
Transparent background. No component names, no technical jargon —
|
||||
concepts only (leader-friendly).
|
||||
|
||||
**Diagram-type-per-slide mapping:**
|
||||
1. flowchart LR — 3 frictions → Nova absorbs them
|
||||
2. flowchart TB — Central IT golden image → Nova one layer up
|
||||
3. flowchart LR with subgraphs — two principles → everything inherits
|
||||
4. flowchart LR (3 columns) — Live · Attested · Stays human
|
||||
5. flowchart LR (two halves) — In Nova's lane / Outside Nova's lane
|
||||
6. timeline — the 18-month shape (α→β→γ→δ)
|
||||
7. flowchart LR — what works now → next steps arc
|
||||
|
||||
The Slide Content Map in PROJECT.md is updated to include a "Diagram"
|
||||
field per slide.
|
||||
**Confidence:** 0.88.
|
||||
|
||||
---
|
||||
|
||||
## Requirements impact
|
||||
|
||||
No requirements are added, removed, or re-scoped by D-241–D-243.
|
||||
D-244–D-246 are polish-phase decisions that record deviations from
|
||||
prior binding decisions (G-1) and the Slide Content Map (slide 7).
|
||||
The spec is locked (v1.0, 2026-08-20); no spec text changes. The
|
||||
Slide Content Map in PROJECT.md is updated to reflect D-245 (slide 7
|
||||
structure) and D-246 (diagram field per slide).
|
||||
@@ -0,0 +1,138 @@
|
||||
# GRILL — v1.30 Single-shot Leadership Deck
|
||||
|
||||
> Adversarial review of the v1.30 SPECIFY + CLARIFY + RESEARCH +
|
||||
> PLAN. Griller: lead-developer (acting as ci-griller at full
|
||||
> autonomy). All 9 axes reviewed; every claim verified against the
|
||||
> live codebase.
|
||||
|
||||
---
|
||||
|
||||
## Overall verdict: **PROCEED** · Confidence 0.88
|
||||
|
||||
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).
|
||||
|
||||
The lower confidence vs. a "clean 0.95" reflects two residual
|
||||
risks: (1) the python-pptx user-site install is environment-fragile
|
||||
(it works now but is not reproducible in a fresh CI runner without
|
||||
the same `--break-system-packages` path); (2) the PPTX footer
|
||||
textbox is a new renderer behavior that needs visual confirmation.
|
||||
Both are mitigated — (1) by the smoke-test hard-fail gate (8f) which
|
||||
forces render success before ship, and (2) by the verify stage's
|
||||
visual review (REQ-372.7).
|
||||
|
||||
---
|
||||
|
||||
## Axis verdicts
|
||||
|
||||
| Axis | Verdict | Confidence | Tracked condition |
|
||||
|------|---------|-----------|-------------------|
|
||||
| §1 Feasibility | PROCEED | 0.90 | T-1.1 footer textbox overlap |
|
||||
| §2 Scope | PROCEED | 0.92 | T-2.1 single-shot discipline enforcement |
|
||||
| §3 Cost | PROCEED | 0.95 | (none — smallest milestone in project history) |
|
||||
| §4 Schedule | PROCEED | 0.93 | (none — 1 execution phase) |
|
||||
| §5 Technical Depth | PROCEED | 0.86 | T-5.1 speaker notes word-band parsing; T-5.2 `→` bullet rendering |
|
||||
| §6 Operational Readiness | PROCEED | 0.90 | T-6.1 render env reproducibility |
|
||||
| §7 Security Posture | PROCEED | 0.95 | (none — static artifact, no runtime surface) |
|
||||
| §8 Dependency Risk | PROCEED | 0.84 | T-8.1 python-pptx user-site install |
|
||||
| §9 Re-mapping Integrity | PROCEED | 0.92 | T-9.1 STATE.md intake override applied |
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
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
|
||||
spike-runner-equivalent permissions to the runner via `sts:AssumeRole`,
|
||||
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
|
||||
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:
|
||||
|
||||
- **Role name:** `acdl-act-runner-role`
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
project: acdl
|
||||
milestone: v1.30
|
||||
generated_at: 2026-08-20
|
||||
generator: lead-developer
|
||||
verification_toolchain:
|
||||
typecheck: "python3 -m py_compile scripts/render_pptx.py 2>&1 | head -5 || true"
|
||||
test: "bash scripts/check_leadership_deck.sh 2>&1 | tail -20; echo \"exit=$?\""
|
||||
lint: "python3 -c \"import pptx; print('python-pptx', pptx.__version__)\" 2>&1"
|
||||
note: |
|
||||
v1.30 is a single-shot presentation artifact milestone (Leadership
|
||||
Deck). Four active personas: lead-developer (coordination + STATE.md
|
||||
CAP-042 + PROJECT.md D-241), backend-engineer (render_pptx.py
|
||||
extension + PPTX render + python-pptx install), ci-doc-writer
|
||||
(custom, phase-specific — Marp markdown deck authoring), ci-cli-
|
||||
engineer (custom — smoke-test script). frontend-engineer +
|
||||
data-engineer + security-engineer deactivated (no UI, no data
|
||||
pipelines, no runtime security surface — the deck is a static
|
||||
artifact). The render_pptx.py extension (D-242) is the only code
|
||||
change; it is a narrow prerequisite, not a REQ-372 deliverable.
|
||||
---
|
||||
|
||||
# Personas — v1.30 Single-shot Leadership Deck
|
||||
|
||||
## Roster
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Milestone coordination, STATE.md CAP-042, PROJECT.md D-241 record, ship discipline"
|
||||
frameworks: []
|
||||
constraints: ["pragmatic", "battle-tested defaults", "D-241", "D-242", "D-243"]
|
||||
territory:
|
||||
- ".ciagent/STATE.md"
|
||||
- ".ciagent/PROJECT.md"
|
||||
- ".ciagent/CHECKPOINT.json"
|
||||
- ".ciagent/REQUIREMENTS.md"
|
||||
- ".ciagent/ROADMAP.md"
|
||||
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."
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "scripts/render_pptx.py extension (path arg + custom output + footer textbox), PPTX render, python-pptx install"
|
||||
frameworks: ["Python 3.11", "python-pptx 1.0.2", "pip"]
|
||||
constraints: ["D-242", "narrow extension only", "no new renderer", "S&P theme tokens only in source"]
|
||||
territory:
|
||||
- "scripts/render_pptx.py"
|
||||
- "docs/presentations/nova-leadership-deck.pptx"
|
||||
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."
|
||||
```
|
||||
|
||||
### ci-doc-writer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
domain: "Marp markdown deck authoring (7 slides, speaker notes, [1] citations, S&P theme)"
|
||||
frameworks: ["Marp", "Markdown"]
|
||||
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"]
|
||||
territory:
|
||||
- "docs/presentations/nova-leadership-deck-marp.md"
|
||||
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)."
|
||||
```
|
||||
|
||||
### ci-cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Smoke-test script (bash, runnable on demand, NOT a CI gate)"
|
||||
frameworks: ["Bash", "grep", "awk", "wc"]
|
||||
constraints: ["REQ-372.8", "not a CI gate", "exit 0 on pass", "non-zero on fail"]
|
||||
territory:
|
||||
- "scripts/check_leadership_deck.sh"
|
||||
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)."
|
||||
```
|
||||
|
||||
## Deactivated
|
||||
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
reason: "ACDL has no frontend (no package.json); the deck is markdown (ci-doc-writer territory). Already deactivated in config.json personas[3]."
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,275 @@
|
||||
# PLAN — v1.30 Single-shot Leadership Deck
|
||||
|
||||
> **Milestone:** v1.30 (feature — single-shot PPTX leadership deck).
|
||||
> Tags on the **v1.29.x** line: `v1.29.1` (P0) → `v1.29.2` (P1
|
||||
> execution) → `v1.29.3` (P2 final = milestone release). The final
|
||||
> 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.
|
||||
|
||||
## Milestone goal
|
||||
|
||||
A single-shot, 7-slide PPTX leadership deck for Infrastructure &
|
||||
Operations leadership (CTO + VP Technology + Product Management),
|
||||
presented live in August 2026, securing architecture endorsement and
|
||||
a November 2026 runway. Authored as Marp markdown, rendered via the
|
||||
existing `scripts/render_pptx.py` (narrowly extended per D-242),
|
||||
verified by `scripts/check_leadership_deck.sh`. The deck is discrete
|
||||
from the existing citizen-developer pitch (D-241: NOT a compression).
|
||||
|
||||
## Requirements
|
||||
|
||||
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 P1 — leadership-deck (REQ-372.1..REQ-372.12)
|
||||
|
||||
**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.
|
||||
|
||||
**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:**
|
||||
- `python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md --output docs/presentations/nova-leadership-deck.pptx` works.
|
||||
- `python3 scripts/render_pptx.py nova-autonomous-cloud-delivery` still works (backward compatible — renders `{deck}-marp.md` → `{deck}-python.pptx`).
|
||||
- Every rendered slide has a right-aligned footer textbox.
|
||||
- `python3 -m py_compile scripts/render_pptx.py` exits 0.
|
||||
|
||||
**REQs covered:** (prerequisite, not REQ-372 directly — per spec §3.3
|
||||
Edge 2 + D-242).
|
||||
|
||||
#### Wave 2 — deck source (ci-doc-writer)
|
||||
|
||||
**Task P1.W2.T1:** Author
|
||||
`docs/presentations/nova-leadership-deck-marp.md`:
|
||||
- **Header comment (REQ-372.9):** an HTML comment at the top (before
|
||||
frontmatter) naming this deck as the leadership artifact for
|
||||
Infrastructure & Operations, August 2026 presentation date, and
|
||||
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;
|
||||
size: 16:9` + a `style:` block using ONLY the 4 S&P tokens
|
||||
(`#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0`). **Replace the
|
||||
existing deck's `#2E2E2E` blockquote color with `#1B1B1B`** to
|
||||
satisfy REQ-372.6.
|
||||
- **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:**
|
||||
- File exists, parses as valid Marp, exactly 7 `---`-delimited slides.
|
||||
- Header comment present with all 3 elements (REQ-372.9).
|
||||
- Frontmatter has the exact footer string + `paginate: false`.
|
||||
- Per-slide speaker-note word counts in band.
|
||||
- `[1]` present in slides 3, 5, 7 speaker notes.
|
||||
- `grep -oiE '#[0-9A-Fa-f]{6}'` returns only the 4 S&P tokens.
|
||||
|
||||
**REQs covered:** REQ-372.1, REQ-372.3, REQ-372.4, REQ-372.6,
|
||||
REQ-372.7, REQ-372.9, REQ-372.12.
|
||||
|
||||
#### Wave 3 — smoke test (ci-cli-engineer)
|
||||
|
||||
**Task P1.W3.T1:** Author `scripts/check_leadership_deck.sh`:
|
||||
- Shebang `#!/usr/bin/env bash`, `set -euo pipefail`, header comment
|
||||
with Usage + Returns.
|
||||
- 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).
|
||||
- (c) per-slide speaker-note word counts in band (extract `<!-- ...
|
||||
-->` content per slide, `wc -w`; slides 1/2/4/6: 150–300; 3/5:
|
||||
250–400; 7: 200–300). Exit non-zero on violation.
|
||||
- (d) footer string `Nova Platform - Infrastructure & Operations`
|
||||
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:**
|
||||
- `bash scripts/check_leadership_deck.sh` exits 0 after the deck +
|
||||
PPTX are authored/rendered.
|
||||
- Exits non-zero if any assertion fails (test by temporary
|
||||
mutation).
|
||||
|
||||
**REQs covered:** REQ-372.8.
|
||||
|
||||
#### Wave 4 — render + ship-wave records (backend-engineer + lead-developer)
|
||||
|
||||
**Task P1.W4.T1 (backend-engineer):** Render the PPTX:
|
||||
- `python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md --output docs/presentations/nova-leadership-deck.pptx`
|
||||
- Verify: PPTX written with 7 slides, python-pptx raised no
|
||||
exceptions, footer textbox present on every slide.
|
||||
- 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`.
|
||||
|
||||
**Task P1.W4.T3 (lead-developer):** Record D-241 in PROJECT.md:
|
||||
- D-241 entry in the decisions section: single-shot nature, audience,
|
||||
August 2026 anchor + November 2026 runway, explicit decision not
|
||||
to compress the existing citizen-developer deck.
|
||||
|
||||
**Must-haves:**
|
||||
- PPTX exists with 7 slides.
|
||||
- Smoke test exits 0.
|
||||
- CAP-042 row in STATE.md.
|
||||
- D-241 record in PROJECT.md.
|
||||
|
||||
**REQs covered:** REQ-372.2, REQ-372.5, REQ-372.8, REQ-372.10,
|
||||
REQ-372.11.
|
||||
|
||||
### Phase P2 — final-review-ship (review + audit + milestone ship)
|
||||
|
||||
**Goal:** Multi-persona review of the milestone changes, project-
|
||||
health audit, and milestone ship (merge to main, tag `v1.29.3` =
|
||||
milestone release, delete milestone branches).
|
||||
|
||||
**Personas:** lead-developer (review + audit + ship coordination).
|
||||
|
||||
**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.
|
||||
|
||||
## Wave dependency graph
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
W1, W2, W3 are independent (no cross-dependencies at author time).
|
||||
W4 depends on W1 + W2 (render needs the extension + the source) +
|
||||
W3 (smoke test validates the render). W4.T2/T3 (records) depend on
|
||||
W4.T1 (render confirms ship readiness).
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
- **The PPTX deck** (`docs/presentations/nova-leadership-deck.pptx`)
|
||||
— the primary leadership-facing artifact, presented live in August
|
||||
2026.
|
||||
- **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
|
||||
|
||||
**J1 — PO presents the deck live** (from spec §3.2):
|
||||
|
||||
1. PO authors `nova-leadership-deck-marp.md` against the Slide
|
||||
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.)_
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. `bash scripts/check_leadership_deck.sh` exits 0 (all 6 assertions
|
||||
a–f pass).
|
||||
2. `docs/presentations/nova-leadership-deck.pptx` exists, has 7
|
||||
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).
|
||||
|
||||
## Risks (from RESEARCH + GRILL)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| Footer textbox overlaps content | Place footer at `SLIDE_H - 0.3"` (bottom margin); content area tops out at ~6.5". No overlap. |
|
||||
| Speaker notes word-count band violation | ci-doc-writer counts words per slide during authoring; smoke test (8c) is the gate. |
|
||||
| `→` lines render as plain text (not bullets) | Use `- → ...` bullets so the renderer treats them as bullet blocks with the arrow in the text. |
|
||||
| `*italic*` in source matches unordered-list regex | Verified in RESEARCH R1: `*italic*` (no space after `*`) does NOT match `[-*+]\s+`. Safe. |
|
||||
| CAP-024 regression policy collides | Verified in RESEARCH R7: CAP-024 validates fixtures, not deck files. No collision. |
|
||||
| `slides.yml` CI interferes | Verified in RESEARCH R8: CI only renders the citizen-developer deck (hardcoded DECK). No interference. |
|
||||
@@ -8,12 +8,28 @@ state for offline agent loading.
|
||||
|
||||
## Why archive
|
||||
|
||||
The active milestone is v1.26 (Live Pilot Estate Activation). The
|
||||
`.ciagent/` root held ~11,164 lines dominated by completed-milestone
|
||||
narratives (v1.0–v1.24). 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.
|
||||
The active milestone is v1.30 (Single-shot Leadership Deck, complete).
|
||||
The `.ciagent/` root was compressed three times:
|
||||
|
||||
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.
|
||||
3. **v1.30 post-milestone compression** (17 → 8 files): the v1.30
|
||||
phase-specific pre-execution files (CLARIFY/RESEARCH/GRILL/PLAN/
|
||||
PERSONAS) were snapshotted to the archive and removed from the
|
||||
active root — they are regenerated fresh each milestone. The stale
|
||||
IDEATE (v1.27), IAM_POLICY (v1.28), and REGRESSION_REPORT (v1.26)
|
||||
were also archived. The persistent files (PROJECT, REQUIREMENTS,
|
||||
ROADMAP, STATE, ARCHITECTURE, NORTH_STAR, config, CHECKPOINT)
|
||||
remain in the active root.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -40,6 +56,47 @@ architecture reference.
|
||||
| `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
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# RESEARCH — v1.30 Single-shot Leadership Deck
|
||||
|
||||
> **Autonomy:** full. Research findings load-bearing for v1.30 PLAN.
|
||||
> The research scope is narrow: this is a single-shot presentation
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## R1 — Existing render pipeline (`scripts/render_pptx.py`)
|
||||
|
||||
**Source:** `scripts/render_pptx.py` (688 lines, REQ-269 v1.23).
|
||||
|
||||
**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.
|
||||
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
## R2 — Smoke-test script conventions
|
||||
|
||||
**Source:** `scripts/check_north_star_diff.sh` (REQ-204), other
|
||||
`scripts/check_*.sh` / `scripts/run_*.sh`.
|
||||
|
||||
**Conventions:**
|
||||
- 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`
|
||||
|
||||
**v1.30 smoke test (`scripts/check_leadership_deck.sh`) assertions
|
||||
(REQ-372.8 a–f):**
|
||||
- (a) `docs/presentations/nova-leadership-deck-marp.md` exists
|
||||
- (b) slide count = 7 (count `---` separators on own line, excluding
|
||||
frontmatter)
|
||||
- (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)
|
||||
|
||||
**Implementation approach:** pure bash + `grep`/`awk`/`wc`. No
|
||||
python dependency for the smoke test (keeps it runnable on demand
|
||||
without the python-pptx install). Slide count: count lines matching
|
||||
`^---\s*$` after the frontmatter, +1. Speaker notes: per slide,
|
||||
extract content between `<!--` and `-->`, strip HTML comment markers,
|
||||
`wc -w`. Color scan: `grep -oiE '#[0-9A-Fa-f]{6}'` on the source,
|
||||
sort -u, compare to allow-list.
|
||||
|
||||
---
|
||||
|
||||
## R3 — Marp frontmatter / footer / speaker-notes handling
|
||||
|
||||
**Source:** `docs/presentations/nova-autonomous-cloud-delivery-marp.md`
|
||||
(lines 1-27), Marp CLI v4.5.0 (available via npx).
|
||||
|
||||
**Existing deck frontmatter:**
|
||||
```yaml
|
||||
marp: true
|
||||
theme: default
|
||||
paginate: true
|
||||
size: 16x9
|
||||
footer: 'Nova — The Autonomous Cloud Delivery Platform'
|
||||
style: |
|
||||
section { ... color: #1B1B1B; ... }
|
||||
h1 { color: #D6002A; ... }
|
||||
...
|
||||
```
|
||||
|
||||
**v1.30 leadership deck frontmatter (per cover note + spec):**
|
||||
```yaml
|
||||
marp: true
|
||||
theme: default
|
||||
footer: "Nova Platform - Infrastructure & Operations"
|
||||
paginate: false
|
||||
size: 16x9
|
||||
style: |
|
||||
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; }
|
||||
...
|
||||
```
|
||||
|
||||
**Key differences from the existing deck:**
|
||||
- `paginate: false` (existing: `true`) — per cover note.
|
||||
- `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`.
|
||||
|
||||
**Speaker notes:** HTML comments `<!-- ... -->` within the slide
|
||||
body, before the next `---`. The Marp CLI renders these as speaker
|
||||
notes in the HTML/PPTX; the python-pptx path skips them. The smoke
|
||||
test extracts them from the *source* for word-count checking.
|
||||
|
||||
---
|
||||
|
||||
## R4 — Theme-token enforcement strategy
|
||||
|
||||
**REQ-372.6:** only `#D6002A`, `#1B1B1B`, `#FFFFFF`, `#F0F0F0` as hex
|
||||
colors in the source.
|
||||
|
||||
**Enforcement:**
|
||||
1. **Source `style:` block:** use only the 4 tokens. Replace the
|
||||
existing deck's `#2E2E2E` (blockquote color) with `#1B1B1B`.
|
||||
2. **No inline `color:` overrides** in slide bodies — the slides use
|
||||
no inline HTML/color spans.
|
||||
3. **Smoke test (8e):** `grep -oiE '#[0-9A-Fa-f]{6}'` on the source,
|
||||
`sort -u`, compare to the 4-token allow-list. Any other hex color
|
||||
→ fail.
|
||||
|
||||
---
|
||||
|
||||
## R5 — python-pptx install path (this environment)
|
||||
|
||||
**Environment:** Debian/Ubuntu, Python 3.11.2, no system pip, no
|
||||
root, no `python3-venv`/`python3-pip` packages, no `ensurepip`.
|
||||
|
||||
**Resolved install path:**
|
||||
1. `curl -sS https://bootstrap.pypa.io/get-pip.py -o /home/opencode/tmp/get-pip.py`
|
||||
2. `python3 get-pip.py --user --break-system-packages`
|
||||
3. `/home/opencode/.local/bin/pip install --user --break-system-packages "python-pptx>=0.6.23"`
|
||||
4. `pip install --user --break-system-packages "pytest>=8.0"` (for
|
||||
verify stage)
|
||||
|
||||
**Result:** python-pptx 1.0.2 + pytest 9.1.1 installed to user-site.
|
||||
`python3 -c "import pptx"` succeeds. No Chromium needed (python-pptx
|
||||
is the render path, not Marp CLI PPTX).
|
||||
|
||||
**Confirmed in RESEARCH execution:** all commands ran successfully
|
||||
in this session.
|
||||
|
||||
---
|
||||
|
||||
## R6 — Vision document grounding for `[1]` citations
|
||||
|
||||
**Source:** `docs/vision.md` (the spec's `acdl-vision.md` / `[1]`
|
||||
reference).
|
||||
|
||||
**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."
|
||||
|
||||
**Citation convention:** `[1]` in speaker notes, resolving to
|
||||
`docs/vision.md`. The spec §citation-references confirms `[1]` →
|
||||
`acdl-vision.md` (vision document, source [1]).
|
||||
|
||||
---
|
||||
|
||||
## R7 — CAP-024 regression policy (collision check)
|
||||
|
||||
**Source:** `adapters/kyverno-json/policies/regression/cap-024-deck-structure.json`
|
||||
+ `tests/test_regression_policies.py`.
|
||||
|
||||
**Finding:** CAP-024 validates the citizen-developer deck's 4-beat
|
||||
arc (Problem/Solution/Proof/Roadmap+Ask) against fixture files
|
||||
(`clean.json`/`drifted.json` in `tests/fixtures/`), NOT against the
|
||||
actual deck markdown files. The leadership deck
|
||||
(`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).
|
||||
|
||||
---
|
||||
|
||||
## R8 — `slides.yml` CI (non-interference check)
|
||||
|
||||
**Source:** `workflows-src/slides.yml`.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Persona assessment (lead-developer)
|
||||
|
||||
**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`.
|
||||
|
||||
**Deactivated personas:**
|
||||
- **frontend-engineer** — already `active: false` in config (no UI).
|
||||
Confirmed.
|
||||
- **data-engineer** — no schema/migration work in this milestone.
|
||||
Deactivate for v1.30.
|
||||
|
||||
**Territory enforcement:** `warn` (per config).
|
||||
@@ -2,21 +2,28 @@
|
||||
"projects": [
|
||||
{
|
||||
"slug": "acdl",
|
||||
"name": "Nova — The New Dawn of DevSecOps",
|
||||
"name": "Nova \u2014 The New Dawn of DevSecOps",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"slug": "nova-blockchain-exchange",
|
||||
"name": "Nova Pilot Consumer — Blockchain Stock Exchange",
|
||||
"name": "Nova Pilot Consumer \u2014 Blockchain Stock Exchange",
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"active_project": "acdl",
|
||||
"active_projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.27",
|
||||
"active_projects": [
|
||||
"acdl",
|
||||
"nova-blockchain-exchange"
|
||||
],
|
||||
"active_milestone": "v1.31",
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||
"escalation_hooks": [
|
||||
"deploy",
|
||||
"delete_data",
|
||||
"merge_to_main"
|
||||
],
|
||||
"clarify_budget": 10,
|
||||
"decision_confidence_threshold": 0.6,
|
||||
"max_revision_iterations": 3,
|
||||
@@ -42,35 +49,72 @@
|
||||
"escalate_high_severity": true,
|
||||
"bash_allowlist": {
|
||||
"allowed_commands": [
|
||||
"git", "ls", "cat", "head", "tail", "wc",
|
||||
"echo", "mkdir", "cp", "mv", "rm", "touch",
|
||||
"pwd", "which", "env", "printenv",
|
||||
"python3", "pytest", "pip",
|
||||
"terraform", "checkov",
|
||||
"curl", "wget",
|
||||
"docker", "docker-compose"
|
||||
"git",
|
||||
"ls",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"wc",
|
||||
"echo",
|
||||
"mkdir",
|
||||
"cp",
|
||||
"mv",
|
||||
"rm",
|
||||
"touch",
|
||||
"pwd",
|
||||
"which",
|
||||
"env",
|
||||
"printenv",
|
||||
"python3",
|
||||
"pytest",
|
||||
"pip",
|
||||
"terraform",
|
||||
"checkov",
|
||||
"curl",
|
||||
"wget",
|
||||
"docker",
|
||||
"docker-compose"
|
||||
],
|
||||
"max_output_bytes": 1048576,
|
||||
"timeout_ms": 30000,
|
||||
"blocked_env_vars": [
|
||||
"HOME", "PATH", "USER", "SHELL",
|
||||
"AWS_*", "*_TOKEN", "*_KEY", "*_SECRET",
|
||||
"*_PASSWORD", "*_CREDENTIAL",
|
||||
"GITHUB_TOKEN", "GITHUB_API_KEY",
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"HOME",
|
||||
"PATH",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"AWS_*",
|
||||
"*_TOKEN",
|
||||
"*_KEY",
|
||||
"*_SECRET",
|
||||
"*_PASSWORD",
|
||||
"*_CREDENTIAL",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OLLAMA_CLOUD_API_KEY"
|
||||
]
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
"branching_strategy": "flat",
|
||||
"_branching_strategy_note": "Nova uses flat workflow (committed directly to main per established convention since v1.0; renamed ACDL→Nova in v1.15). The 'phase' strategy is advisory; CIAgent uses milestone/phase branches for v1.14 but the project convention is flat.",
|
||||
"_branching_strategy_note": "Nova uses flat workflow (committed directly to main per established convention since v1.0; renamed ACDL\u2192Nova in v1.15). The 'phase' strategy is advisory; CIAgent uses milestone/phase branches for v1.14 but the project convention is flat.",
|
||||
"auto_commit": true,
|
||||
"auto_push": true
|
||||
},
|
||||
"secrets": {
|
||||
"sources": [".env", ".env.secrets", ".env.*"],
|
||||
"disallow": ["shell_env", "netrc", "keychain", "rc_files", "global_config"],
|
||||
"sources": [
|
||||
".env",
|
||||
".env.secrets",
|
||||
".env.*"
|
||||
],
|
||||
"disallow": [
|
||||
"shell_env",
|
||||
"netrc",
|
||||
"keychain",
|
||||
"rc_files",
|
||||
"global_config"
|
||||
],
|
||||
"scopes": {
|
||||
"forge": "NOVA_FORGE_TOKEN",
|
||||
"gitea": "NOVA_FORGE_TOKEN",
|
||||
@@ -112,10 +156,18 @@
|
||||
"backend": {
|
||||
"provider": "auto",
|
||||
"agent_backends": {
|
||||
"opencode": { "enabled": true },
|
||||
"codex": { "enabled": true },
|
||||
"claude-code": { "enabled": true },
|
||||
"hermes": { "enabled": true }
|
||||
"opencode": {
|
||||
"enabled": true
|
||||
},
|
||||
"codex": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-code": {
|
||||
"enabled": true
|
||||
},
|
||||
"hermes": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"llm_backends": {
|
||||
"openai": {
|
||||
@@ -148,7 +200,13 @@
|
||||
},
|
||||
"ideation": {
|
||||
"enabled": true,
|
||||
"categories": ["security", "quality", "architecture", "coverage", "improvement"],
|
||||
"categories": [
|
||||
"security",
|
||||
"quality",
|
||||
"architecture",
|
||||
"coverage",
|
||||
"improvement"
|
||||
],
|
||||
"confidence_threshold": 0.6,
|
||||
"max_ideas": 20,
|
||||
"external_signals": {
|
||||
@@ -162,7 +220,11 @@
|
||||
},
|
||||
"chaos": {
|
||||
"enabled": true,
|
||||
"scenarios": ["backend_unavailable", "requirement_change", "test_coverage_drop"]
|
||||
"scenarios": [
|
||||
"backend_unavailable",
|
||||
"requirement_change",
|
||||
"test_coverage_drop"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
@@ -178,30 +240,77 @@
|
||||
"name": "lead-developer",
|
||||
"domain": "coordination",
|
||||
"frameworks": [],
|
||||
"constraints": ["pragmatic", "battle-tested defaults"],
|
||||
"constraints": [
|
||||
"pragmatic",
|
||||
"battle-tested defaults"
|
||||
],
|
||||
"territory": []
|
||||
},
|
||||
{
|
||||
"name": "data-engineer",
|
||||
"domain": "data",
|
||||
"frameworks": ["drizzle", "postgresql"],
|
||||
"constraints": ["schema-first", "type-safe ORM", "migration-driven"],
|
||||
"territory": ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"]
|
||||
"frameworks": [
|
||||
"drizzle",
|
||||
"postgresql"
|
||||
],
|
||||
"constraints": [
|
||||
"schema-first",
|
||||
"type-safe ORM",
|
||||
"migration-driven"
|
||||
],
|
||||
"territory": [
|
||||
"**/migrations/**",
|
||||
"**/schema/**",
|
||||
"**/models/**",
|
||||
"**/db/**",
|
||||
"prisma/schema.prisma",
|
||||
"drizzle/**",
|
||||
"**/*.sql"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "backend-engineer",
|
||||
"domain": "backend",
|
||||
"frameworks": ["fastify", "hono"],
|
||||
"constraints": ["api-first", "strict-typing", "dependency-injection"],
|
||||
"territory": ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"]
|
||||
"frameworks": [
|
||||
"fastify",
|
||||
"hono"
|
||||
],
|
||||
"constraints": [
|
||||
"api-first",
|
||||
"strict-typing",
|
||||
"dependency-injection"
|
||||
],
|
||||
"territory": [
|
||||
"**/api/**",
|
||||
"**/routes/**",
|
||||
"**/services/**",
|
||||
"**/middleware/**",
|
||||
"**/controllers/**",
|
||||
"**/auth/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "frontend-engineer",
|
||||
"domain": "frontend",
|
||||
"active": false,
|
||||
"frameworks": ["react", "next.js"],
|
||||
"constraints": ["component-first", "server-components", "minimal-client-js"],
|
||||
"territory": ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"],
|
||||
"frameworks": [
|
||||
"react",
|
||||
"next.js"
|
||||
],
|
||||
"constraints": [
|
||||
"component-first",
|
||||
"server-components",
|
||||
"minimal-client-js"
|
||||
],
|
||||
"territory": [
|
||||
"**/components/**",
|
||||
"**/pages/**",
|
||||
"**/hooks/**",
|
||||
"**/styles/**",
|
||||
"**/*.tsx",
|
||||
"**/*.css",
|
||||
"**/*.vue"
|
||||
],
|
||||
"reason": "ACDL has no frontend (no package.json); decks are markdown (lead-developer territory). Deactivated per PERSONAS.md:80."
|
||||
}
|
||||
]
|
||||
@@ -220,4 +329,4 @@
|
||||
"engine": "kyverno-json",
|
||||
"policy_root": "adapters/kyverno-json/policies"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,4 +89,9 @@ of normal operations.
|
||||
- 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`.
|
||||
`/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).
|
||||
@@ -5,7 +5,7 @@
|
||||
> 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/P4-PILOT-RUN-EVIDENCE.md`.
|
||||
> `.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
|
||||
@@ -20,12 +20,13 @@ Ledger. The consumer never clones the platform repo and never runs
|
||||
|
||||
## 1. Invoke the deploy
|
||||
|
||||
The consumer's `.github/workflows/deploy.yml` (and its byte-identical
|
||||
The consumer's `.github/workflows/deploy.yml` (and its
|
||||
`.gitea/workflows/deploy.yml` mirror) is a `workflow_dispatch` workflow.
|
||||
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.25` into
|
||||
`platform/`, then runs `bash platform/scripts/run_platform.sh`.
|
||||
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:
|
||||
|
||||
@@ -161,8 +162,8 @@ Ledger. Neither trusts the other.
|
||||
|
||||
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/P4-PILOT-RUN-EVIDENCE.md` for the full pilot-evidence
|
||||
checklist (every ARN, the confidence JSON, the backfill timestamp).
|
||||
`.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).
|
||||
|
||||
---
|
||||
|
||||
@@ -170,8 +171,8 @@ checklist (every ARN, the confidence JSON, the backfill timestamp).
|
||||
|
||||
- `.ciagent/ARCHITECTURE.md` §12.8 — the pilot-estate architecture
|
||||
(this guide is the consumer-facing companion to that section).
|
||||
- `.ciagent/P4-PILOT-RUN-EVIDENCE.md` — the live-pilot evidence
|
||||
(run `blkex-pilot-apply-v0.2`).
|
||||
- `.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
|
||||
|
||||
@@ -71,21 +71,32 @@ declare `dynamodb` — ECS + S3 already exist.
|
||||
- `tests/test_contract_validates.py` — schema validation against the
|
||||
platform's `schemas/contract.schema.json`.
|
||||
|
||||
### REQ-314 — Consumer deploy workflow invocation ✓ complete (P2, v1.25.2)
|
||||
### REQ-CONSUMER-BUMP — Consumer deploy.yml `@v1.25` → `@v1.29` (v1.29 P5)
|
||||
|
||||
The consumer repo's GitHub/Gitea Actions invoke the Nova platform's
|
||||
reusable `deploy.yml@v1.25` workflow with `mode: full` for the pilot.
|
||||
The workflow checks out the consumer repo + the platform repo, runs
|
||||
`scripts/run_platform.sh`, and records the apply decision + attestation
|
||||
in the Nova Decision Ledger.
|
||||
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.25`
|
||||
- `.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` — byte-identical mirror (the platform's
|
||||
deploy workflow is forge-agnostic).
|
||||
- `tests/test_deploy_workflow_invocation.py` — asserts the `uses:` ref
|
||||
+ inputs are correct.
|
||||
- `.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)
|
||||
|
||||
|
||||
@@ -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,89 +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: 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.25
|
||||
|
||||
- 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: ${{ 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: 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,69 +0,0 @@
|
||||
# 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
|
||||
@@ -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)
|
||||
|
||||
These 3 are generated from `workflows-src/<name>`. Run `python3 scripts/sync_workflows.py --check` to verify
|
||||
no drift.
|
||||
These 3 are generated from `workflows-src/<name>`. D-232 (v1.29): the
|
||||
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 |
|
||||
|----------|---------|--------|------------------|---------|
|
||||
|
||||
@@ -22,6 +22,27 @@ on:
|
||||
branches: [main]
|
||||
|
||||
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:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
@@ -42,3 +42,6 @@ metrics/lifecycle/
|
||||
*.jks
|
||||
*.keystore.coverage
|
||||
.coverage
|
||||
|
||||
.venv/
|
||||
nova.egg-info/
|
||||
|
||||
@@ -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))
|
||||
@@ -169,20 +169,21 @@ def check(env: str, evidence: dict) -> Tuple[bool, str]:
|
||||
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
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: attestation_matrix.py <env> [evidence.json]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
_env = sys.argv[1]
|
||||
if len(argv) < 2:
|
||||
print("usage: attestation_matrix <env> [evidence.json]", file=sys.stderr)
|
||||
return 2
|
||||
_env = argv[1]
|
||||
_evidence = {}
|
||||
if len(sys.argv) >= 3 and os.path.isfile(sys.argv[2]):
|
||||
with open(sys.argv[2]) as f:
|
||||
if len(argv) >= 3 and os.path.isfile(argv[2]):
|
||||
with open(argv[2]) as f:
|
||||
_evidence = json.load(f)
|
||||
ok, reason = check(_env, _evidence)
|
||||
if ok:
|
||||
print(f"ATTESTATION PASS: {reason}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"ATTESTATION PASS: {reason}") if ok else print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
@@ -218,12 +218,18 @@ def compute(contract_id: str, environment: str,
|
||||
return signal
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: confidence_signal.py <inputs.json> <environment>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
env = sys.argv[2]
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
def cli_main(argv) -> int:
|
||||
"""Thin CLI entry (P1): nova confidence <inputs.json> <environment>."""
|
||||
if len(argv) < 3:
|
||||
print("usage: confidence <inputs.json> <environment>", file=sys.stderr)
|
||||
return 2
|
||||
env = argv[2]
|
||||
with open(argv[1], "r", encoding="utf-8") as fh:
|
||||
inputs = json.load(fh)
|
||||
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))
|
||||
@@ -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
|
||||
`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
|
||||
parser in `core/regression_verify.py`) were updated to NOVA-only in P5
|
||||
(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
|
||||
|
||||
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]:
|
||||
@@ -28,4 +41,85 @@ def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
val = os.environ.get(f"NOVA_{name}")
|
||||
if 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,
|
||||
}
|
||||
@@ -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):
|
||||
"""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
|
||||
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
||||
Parses the Lambda-specific envelope then delegates to the shared
|
||||
``dispatch_action`` business logic.
|
||||
"""
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
if isinstance(body, str):
|
||||
payload = json.loads(body)
|
||||
else:
|
||||
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)})}
|
||||
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: # 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) ---------------------------
|
||||
# Invoked as: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||
# Delegates to core.submission_readiness.check_readiness() and prints the
|
||||
# structured ReadinessResult. Exits 0 if ready, 1 if not.
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the contract ingestor (REQ-329 dual-use).
|
||||
|
||||
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
|
||||
import sys
|
||||
if "--check-readiness" in sys.argv:
|
||||
sys.path.insert(
|
||||
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.
|
||||
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:
|
||||
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())
|
||||
@@ -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)
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,385 @@
|
||||
# Operator Guide — Nova IdP Setup (`nova idp setup`)
|
||||
|
||||
> **REQ-345** — operator guide for `nova idp setup`. Covers `--check`,
|
||||
> `--apply`, `--verify`, the prerequisite IAM policy, the CloudFormation
|
||||
> review flow, and the **C-6.3 grill additions**: KMS key rotation
|
||||
> (90 days), Lambda layer update, DDB PITR restore, emergency PAT
|
||||
> revocation (DDB-level, not CLI).
|
||||
>
|
||||
> Audience: platform operators / SREs deploying the Nova identity stack
|
||||
> into AWS account `581513795199` (or a fresh account). No developer
|
||||
> auth flows here — see `docs/developer-guide-auth.md` for those.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
`nova idp setup` provisions the Nova identity layer (Nova-idp) as a
|
||||
CloudFormation stack. The stack contains:
|
||||
|
||||
| Resource | Count | Notes |
|
||||
|----------|-------|-------|
|
||||
| Lambda functions | 3 | `nova-idp-auth`, `nova-idp-token-vend`, `nova-idp-jwks` |
|
||||
| DynamoDB tables | 4 | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` (PITR enabled on each, REQ-335) |
|
||||
| KMS asymmetric key | 1 | `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) |
|
||||
| Lambda function URLs | 3 | auth + token-vend (IAM auth), jwks (`AuthType: NONE`) |
|
||||
| IAM roles | 3+ | one per Lambda + the CloudFormation service role |
|
||||
| Optional CloudFront + WAF + ACM | 0/3 | only with `--public-jwks-domain` |
|
||||
|
||||
The command has three modes — `--check`, `--apply`, `--verify` — plus
|
||||
`--dry-run` for a resource-only preview. All modes are safe to re-run.
|
||||
|
||||
## 2. `nova idp setup --check`
|
||||
|
||||
Run **before** `--apply` to verify the deploying principal has the
|
||||
permissions and environment the stack needs.
|
||||
|
||||
```sh
|
||||
nova idp setup --check
|
||||
```
|
||||
|
||||
### What it checks
|
||||
|
||||
1. **AWS credentials** — `aws sts get-caller-identity` succeeds and
|
||||
returns an `Account` id. If this fails, run `aws configure` or export
|
||||
`AWS_PROFILE` / `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`.
|
||||
2. **AWS region** — `AWS_DEFAULT_REGION` or `AWS_REGION` is set. The
|
||||
stack is regional (single-region); pick the region you want all
|
||||
resources to live in.
|
||||
3. **CloudFormation permissions** — the principal can create/describe
|
||||
stacks (see §5 for the full IAM delta).
|
||||
4. **KMS permissions** — `kms:CreateKey` + `kms:CreateAlias` (needed to
|
||||
mint `alias/nova-oidc-signing`).
|
||||
5. **Lambda layer exists** — the `nova-cli` Lambda layer (published by
|
||||
the P1 Wave 4 pipeline) is referenced by the stack; `--check` reports
|
||||
whether the layer ARN in SSM (`/nova/layer/nova-cli/version`) is
|
||||
present. If absent, run the publish workflow or `nova layer update`.
|
||||
|
||||
### Reading the IAM policy delta
|
||||
|
||||
`--check` prints a report like:
|
||||
|
||||
```json
|
||||
{
|
||||
"aws_creds": true,
|
||||
"region": "us-east-1",
|
||||
"missing": [],
|
||||
"iam_delta": [
|
||||
"cloudformation:*",
|
||||
"iam:CreateRole",
|
||||
"iam:PassRole",
|
||||
"lambda:CreateFunction",
|
||||
"lambda:CreateFunctionUrlConfig",
|
||||
"dynamodb:CreateTable",
|
||||
"kms:CreateKey",
|
||||
"kms:CreateAlias"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`iam_delta` is the **delta** between what the deploying principal
|
||||
currently has (the `nova-spike-runner` grants in this account) and what
|
||||
`--apply` needs. Each entry is a grant you must add to the principal's
|
||||
policy before `--apply` will succeed. `--check` never makes changes.
|
||||
|
||||
## 3. `nova idp setup --apply`
|
||||
|
||||
Generates the CloudFormation template, presents it for review, and
|
||||
deploys **only after explicit `y/N` approval** (NFR-10).
|
||||
|
||||
```sh
|
||||
nova idp setup --apply
|
||||
```
|
||||
|
||||
### Review flow
|
||||
|
||||
1. **Resource summary** printed to stdout (resource type → count):
|
||||
```
|
||||
Resource summary:
|
||||
AWS::DynamoDB::Table: 4
|
||||
AWS::IAM::Role: 3
|
||||
AWS::KMS::Key: 1
|
||||
AWS::Lambda::Function: 3
|
||||
AWS::Lambda::Url: 3
|
||||
```
|
||||
2. **Full template** opened in `$PAGER` (if set and stdin is a TTY);
|
||||
otherwise the path to the temp file is printed. Review every
|
||||
resource, especially the KMS key policy and the IAM roles.
|
||||
3. **`Apply? [y/N]` prompt.** Type `y` + Enter to deploy; anything else
|
||||
aborts. No resource is created before this approval.
|
||||
4. On approval: `aws cloudformation deploy --stack-name nova-idp
|
||||
--template-file <tmp> --capabilities CAPABILITY_IAM`.
|
||||
|
||||
### `--dry-run` — resource list only
|
||||
|
||||
```sh
|
||||
nova idp setup --dry-run
|
||||
```
|
||||
|
||||
Generates the template and prints the resource summary **without** the
|
||||
pager, the prompt, or any deploy. Use this to audit the stack shape in
|
||||
CI or before a manual `--apply`.
|
||||
|
||||
### `--public-jwks-domain` — optional custom domain + WAF
|
||||
|
||||
```sh
|
||||
nova idp setup --apply --public-jwks-domain jwks.nova.example.com
|
||||
```
|
||||
|
||||
Adds a CloudFront distribution fronting the JWKS Lambda function URL, an
|
||||
ACM certificate (DNS-validated) for the domain, and a WAF web ACL with
|
||||
a rate-based rule (see §C-6.3 and the threat model). Without this flag
|
||||
the JWKS endpoint is a bare function URL (`AuthType: NONE`) — fine for
|
||||
piloting but exposed to the internet without rate limiting. **For any
|
||||
public deployment, set `--public-jwks-domain`.**
|
||||
|
||||
## 4. `nova idp setup --verify`
|
||||
|
||||
Runs the KMS round-trip test (CAP-037) against the deployed stack.
|
||||
|
||||
```sh
|
||||
nova idp setup --verify
|
||||
```
|
||||
|
||||
It signs a test JWT via `core.kms_signing.sign_jwt()` (using the real
|
||||
KMS key `alias/nova-oidc-signing`), fetches the JWKS endpoint, and
|
||||
verifies the JWT signature with `pyjwt` + the JWKS key. This exercises
|
||||
the full DER → raw ECDSA conversion path (the #1 implementation risk —
|
||||
see `docs/threat-model.md`).
|
||||
|
||||
**Success output:**
|
||||
```json
|
||||
{"passed": true, "detail": "KMS round-trip OK"}
|
||||
```
|
||||
|
||||
**Failure output:**
|
||||
```json
|
||||
{"passed": false, "detail": "verify error: <exception>"}
|
||||
```
|
||||
|
||||
Common failure causes:
|
||||
- The KMS key policy doesn't grant `kms:Sign` to the verify caller.
|
||||
- The JWKS function URL is not deployed or returns a non-200.
|
||||
- The KMS key spec isn't `ECC_NIST_P256` (the DER→raw conversion
|
||||
assumes P-256, 32-byte coordinates).
|
||||
|
||||
## 5. Required IAM policy
|
||||
|
||||
The delta `--check` reports is the set of grants the deploying
|
||||
principal needs **in addition** to the existing `nova-spike-runner`
|
||||
grants. The full required set:
|
||||
|
||||
| Action | Why |
|
||||
|--------|-----|
|
||||
| `cloudformation:*` | create/deploy/describe the `nova-idp` stack |
|
||||
| `codeartifact:*` | (already on `nova-spike-runner`) publish the wheel + layer |
|
||||
| `iam:CreateRole` | create the per-Lambda execution roles |
|
||||
| `iam:PassRole` | pass those roles to Lambda + CloudFormation |
|
||||
| `lambda:CreateFunction` | create the 3 Lambda functions |
|
||||
| `lambda:CreateFunctionUrlConfig` | create the 3 function URLs |
|
||||
| `dynamodb:CreateTable` | create the 4 DDB tables (with PITR) |
|
||||
| `kms:CreateKey` | mint the `ECC_NIST_P256` signing key |
|
||||
| `kms:CreateAlias` | bind `alias/nova-oidc-signing` to the key |
|
||||
| `ssm:PutParameter` | write the layer-version mapping to SSM |
|
||||
|
||||
Attach these to the deploying principal's policy before `--apply`.
|
||||
`--check` will then report an empty `missing` list.
|
||||
|
||||
---
|
||||
|
||||
## C-6.3 Grill additions — operational runbooks
|
||||
|
||||
The grill (C-6.3) requires four operational procedures beyond the
|
||||
setup flow. Each is a runbook an on-call SRE can follow without reading
|
||||
source code.
|
||||
|
||||
### 6. KMS key rotation (90-day cadence)
|
||||
|
||||
**Cadence:** rotate `alias/nova-oidc-signing` every **90 days**. The
|
||||
rotation is a *key re-point*, not a key deletion — the alias is moved
|
||||
to a new key while the old key stays valid during the token-overlap
|
||||
window so already-issued tokens keep verifying.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. **Create the new key** (same spec):
|
||||
```sh
|
||||
NEW_KEY=$(aws kms create-key \
|
||||
--key-spec ECC_NIST_P256 \
|
||||
--key-usage SIGN_VERIFY \
|
||||
--description "nova-oidc-signing-$(date +%Y%m%d)" \
|
||||
--query KeyId --output text)
|
||||
```
|
||||
2. **Re-point the alias** to the new key:
|
||||
```sh
|
||||
aws kms update-alias --alias-name alias/nova-oidc-signing \
|
||||
--target-key-id "$NEW_KEY"
|
||||
```
|
||||
3. **JWKS serves both `kid`s during the overlap window.** The JWKS
|
||||
Lambda lists **all** keys the alias has pointed at that are still
|
||||
enabled. Already-issued OIDC tokens (signed with the old key) keep
|
||||
verifying until they expire (OIDC TTL default 15 min; PAT TTL ≤ 24h
|
||||
dev / ≤ 1h service-account). **Do not disable the old key until at
|
||||
least the max PAT TTL (24h) has elapsed.**
|
||||
4. **After the overlap window** (≥ 24h), disable + schedule deletion of
|
||||
the old key:
|
||||
```sh
|
||||
aws kms disable-key --key-id "<old-key-id>"
|
||||
aws kms schedule-key-deletion --key-id "<old-key-id>" --pending-window-in-days 7
|
||||
```
|
||||
5. **Verify** the new key is active:
|
||||
```sh
|
||||
nova idp setup --verify
|
||||
```
|
||||
|
||||
**Audit:** emit a manual `kms.key_rotated` event to the audit stream
|
||||
with `old_key_id`, `new_key_id`, `rotated_at`. The rotation is a
|
||||
CloudFormation-less operation (KMS aliases are mutable); it does not
|
||||
require a stack update.
|
||||
|
||||
### 7. Lambda layer update
|
||||
|
||||
The `nova-cli` Lambda layer (the shared dependency bundle:
|
||||
`argon2-cffi`, `cryptography`, `pyjwt`, `kj` binary) is republished
|
||||
**automatically on every merge to `main`** by the P1 Wave 4 publish
|
||||
workflow (the byte-identical GitHub + internal-forge workflow files).
|
||||
On a successful publish, the new layer version ARN is written to SSM
|
||||
`/nova/layer/nova-cli/version`.
|
||||
|
||||
**When to update manually:**
|
||||
- A dependency CVE requires an out-of-band patch before the next merge.
|
||||
- The `kj` binary pinned version changes (C-8.2 supply-chain safety).
|
||||
|
||||
**Manual procedure:**
|
||||
|
||||
```sh
|
||||
nova layer update
|
||||
```
|
||||
|
||||
This rebuilds the layer (`pip install --target layer/python/` + the
|
||||
pinned `kj` binary, SHA256 verified against `layer/kj.sha256`),
|
||||
publishes a new `lambda:PublishLayerVersion`, and updates the SSM
|
||||
parameter. The 3 Nova-idp Lambdas pick up the new layer on their next
|
||||
cold start (or force a redeploy with `aws lambda update-function-configuration
|
||||
--layers <new-arn>` on each).
|
||||
|
||||
**Verify:** `nova idp setup --verify` after the Lambdas reload.
|
||||
|
||||
### 8. DynamoDB PITR restore
|
||||
|
||||
All 4 identity tables have point-in-time recovery (PITR) enabled
|
||||
(REQ-335): `nova-users`, `nova-sessions`, `nova-password-resets`,
|
||||
`nova-pats`. PITR lets you restore a table to any second in the last
|
||||
**35 days** (the AWS retention window).
|
||||
|
||||
**Procedure (restore `nova-pats` to 1 hour ago):**
|
||||
|
||||
```sh
|
||||
# 1. Find the restore target time (ISO 8601, UTC, within the last 35d).
|
||||
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# 2. Restore to a NEW table (PITR never overwrites the source).
|
||||
aws dynamodb restore-table-to-point-in-time \
|
||||
--source-table-name nova-pats \
|
||||
--target-table-name nova-pats-restored \
|
||||
--restore-date-time "$RESTORE_TO" \
|
||||
--billing-mode-restore-as-is
|
||||
|
||||
# 3. After the restore completes (status ACTIVE), repoint the app:
|
||||
# - update the stack env var NOVA_PATS_TABLE=nova-pats-restored, or
|
||||
# - rename: delete nova-pats, then aws dynamodb update-table --table-name
|
||||
# nova-pats-restored --new-table-name nova-pats (downtime window).
|
||||
# 4. Re-enable PITR on the restored table (PITR does not carry over).
|
||||
aws dynamodb update-continuous-backups \
|
||||
--table-name nova-pats-restored \
|
||||
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
|
||||
```
|
||||
|
||||
**Which tables have PITR:** all 4 (`nova-users`, `nova-sessions`,
|
||||
`nova-password-resets`, `nova-pats`). Verify with:
|
||||
```sh
|
||||
for t in nova-users nova-sessions nova-password-resets nova-pats; do
|
||||
aws dynamodb describe-continuous-backups --table-name "$t" \
|
||||
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription' --output text
|
||||
done
|
||||
```
|
||||
|
||||
**Recovery window:** 35 days (AWS PITR). Restores older than 35 days
|
||||
are impossible — for longer retention, export to S3 via the on-demand
|
||||
export or a scheduled AWS Backup plan.
|
||||
|
||||
### 9. Emergency PAT revocation (DDB-level, not CLI)
|
||||
|
||||
**When to use:** a PAT is known-compromised and the `nova auth revoke`
|
||||
CLI is unavailable (e.g. the operator machine is offline, or the PAT
|
||||
`jti` is known but the raw PAT is not — revocation is keyed on `jti`,
|
||||
not the token string). This is a **DDB-level** operation; it bypasses
|
||||
the CLI but still satisfies the D-229 strong-read SLO (the token-vend
|
||||
Lambda does a `ConsistentRead=True` `GetItem` on `jti` on every vend —
|
||||
the revocation is reflected on the next vend, within 60s P95).
|
||||
|
||||
**Procedure:**
|
||||
|
||||
```sh
|
||||
aws dynamodb update-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<jti>"}}' \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
```
|
||||
|
||||
Replace `<jti>` with the PAT's `jti` claim (a uuid4; find it in the
|
||||
`pat.issued` audit event or by scanning the `sub-index` GSI for the
|
||||
compromised subject). The item is **retained** (not deleted) so the
|
||||
audit trail is intact — only `status` flips from `active` to `revoked`.
|
||||
|
||||
**Verify the revocation took effect:**
|
||||
|
||||
```sh
|
||||
aws dynamodb get-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<jti>"}}' \
|
||||
--consistent-read \
|
||||
--query 'Item.status.S' --output text
|
||||
# → revoked
|
||||
```
|
||||
|
||||
The next `token-vend` call with that `jti` returns `403
|
||||
pat_revoked` immediately (D-229: the strong read is synchronous).
|
||||
|
||||
**Bulk revocation** (revoke all of a subject's PATs):
|
||||
|
||||
```sh
|
||||
SUB="<sub>"
|
||||
JTIS=$(aws dynamodb query \
|
||||
--table-name nova-pats \
|
||||
--index-name sub-index \
|
||||
--key-condition-expression "sub = :s" \
|
||||
--expression-attribute-values "{\":s\":{\"S\":\"$SUB\"}}" \
|
||||
--query 'Items[?status.S==`active`].jti.S' --output text)
|
||||
for jti in $JTIS; do
|
||||
aws dynamodb update-item --table-name nova-pats \
|
||||
--key "{\"jti\":{\"S\":\"$jti\"}}" \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix — quick reference
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `nova idp setup --check` | prerequisites + IAM delta (no changes) |
|
||||
| `nova idp setup --dry-run` | resource summary only (no deploy) |
|
||||
| `nova idp setup --apply` | review template → `y/N` → deploy |
|
||||
| `nova idp setup --apply --public-jwks-domain <fqdn>` | add CloudFront + WAF + ACM |
|
||||
| `nova idp setup --verify` | KMS round-trip test (CAP-037) |
|
||||
|
||||
| Runbook | Cadence / trigger |
|
||||
|---------|-------------------|
|
||||
| KMS key rotation | every 90 days |
|
||||
| Lambda layer update | on merge (auto) or manually via `nova layer update` |
|
||||
| DDB PITR restore | on data loss / corruption (35-day window) |
|
||||
| Emergency PAT revocation | on compromise (DDB-level, immediate) |
|
||||
@@ -0,0 +1,747 @@
|
||||
# Operator Guide — Nova Platform Ops (`nova-platform-ops`)
|
||||
|
||||
> **REQ-OPS-GUIDE** — the operator-facing runbook for the
|
||||
> `nova-platform-ops` Terraform repo. This is the verification surface
|
||||
> for the covered-reference REQs (355-366, 371): their cutover gates
|
||||
> (M1/M1.5/M2) are documented in §18 below, and each REQ has a
|
||||
> **"Result" column** that the operator fills in after running the gate.
|
||||
> P6 audit verifies every covered-reference REQ has a non-empty, green
|
||||
> Result (grill CF-2/G-5). **HARD P6 ship gate:** §3 contains the
|
||||
> operator-attested "M1.5 Verification Gate Result" row (grill
|
||||
> CF-1/G-2.1) — the milestone does not ship until that row is filled.
|
||||
>
|
||||
> Audience: platform operators / SREs running the live Nova platform in
|
||||
> AWS account `581513795199`. For the developer auth flows, see
|
||||
> `docs/developer-guide-auth.md`; for the legacy CloudFormation path,
|
||||
> see `docs/archive/nova-idp-cfn-v1.28.md`.
|
||||
|
||||
## 1. Overview + reposplit rationale
|
||||
|
||||
Nova's platform operations live in a dedicated, Gitea-private Terraform
|
||||
repository — `nova-platform-ops` — separate from the engineering repo
|
||||
`acdl/acdl`. The split is grounded in Vision §4 (Domain Boundaries):
|
||||
|
||||
> *The platform begins where the artifact is compiled and ends where it
|
||||
> runs in production under operational guardrails.*
|
||||
|
||||
That is two distinct disciplines with two distinct ownership surfaces:
|
||||
|
||||
| Discipline | Ends | Begins | Repo | Surface |
|
||||
|------------|------|--------|------|---------|
|
||||
| Engineering | at the compiled artifact | — | `acdl/acdl` (GitHub) | `publish.yml` + GitHub Releases |
|
||||
| Operations | — | at the live platform under guardrails | `nova-platform-ops` (Gitea-private) | Terraform modules |
|
||||
|
||||
**Scope split (CLARIFY Q-P1, D-232):**
|
||||
|
||||
- `acdl/acdl` authors `publish.yml` (the artifact publish pipeline) +
|
||||
the artifacts themselves (Lambda zip, layer wheel, Python wheel, ECR
|
||||
container image with the static `kj` binary). Each tag `v1.29.x`
|
||||
produces a GitHub Release with SHA-256-verified artifacts (REQ-354).
|
||||
- `nova-platform-ops` authors the Terraform modules
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
|
||||
that bring those artifacts live in `581513795199`.
|
||||
|
||||
The handoff between the two repos is the **tag-pin** (D-235):
|
||||
`nova-platform-ops` declares `local.nova_platform_version` +
|
||||
`local.kj_source_sha` and resolves substrates through a single
|
||||
`data.aws_ecr_image.kj_image`. The engineering repo never knows which
|
||||
tag is live; the ops repo never authors artifacts. Vision §6
|
||||
immutability + Vision §5 narrow interfaces.
|
||||
|
||||
The covered-reference REQs (355-366, 371) are authored in
|
||||
`nova-platform-ops` (out-of-band). CIAgent in `acdl` tracks them for
|
||||
milestone completeness; their verification surface is the cutover
|
||||
gates in §18 of this guide.
|
||||
|
||||
## 2. Day-0 cutover procedure (M1)
|
||||
|
||||
The M1 cutover is the one-time conversion of the live AWS account
|
||||
`581513795199` from CloudFormation-managed (or manually-created)
|
||||
resources to Terraform-managed resources in `nova-platform-ops`. It is
|
||||
conditional on the M1.5 verification gate passing (§3, Q7 carry-forward,
|
||||
D-236).
|
||||
|
||||
The 10-step Journey 2 (spec §3.2):
|
||||
|
||||
1. **Create `nova-platform-ops` in Gitea** — private (`private: true`,
|
||||
OPER-PRIV, REQ-359), no GitHub mirror. The repo is operator-owned;
|
||||
CIAgent has no presence there.
|
||||
|
||||
2. **Commit the initial Terraform structure** — the module tree
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
|
||||
+ `importable-resources.tf` (§12) + `versions.tf` + `backend.tf`
|
||||
(S3 state in the imported bucket).
|
||||
|
||||
3. **`terraform init`** — initialize the S3 backend against the
|
||||
state bucket (`nova-tfstate-581513795199-us-east-1`, imported in
|
||||
step 5). The bucket is created manually once (operator's secure
|
||||
scratch, spec §7.1, D-235) before Terraform adopts it.
|
||||
|
||||
4. **`terraform import` for existing live resources** — adopt the
|
||||
resources that already exist in `581513795199` into Terraform state
|
||||
without recreating them. The import map is in
|
||||
`importable-resources.tf` (§12):
|
||||
- `aws_s3_bucket.nova_tfstate` ← `nova-tfstate-581513795199-us-east-1`
|
||||
- `aws_dynamodb_table.nova_contracts` ← `nova-contracts`
|
||||
- `aws_dynamodb_table.nova_change_requests` ← `nova-change-requests`
|
||||
- `aws_dynamodb_table.nova_outbox` ← `nova-outbox`
|
||||
- `aws_iam_role.acdl_act_runner` ← `acdl-act-runner-role`
|
||||
- per-stack CMKs (KMS keys)
|
||||
|
||||
Re-import exits non-zero with `resource_already_imported`
|
||||
(IMPORT-IDEMPOTENT, REQ-361). CI import treats this as idempotent
|
||||
success — the import workflow greps the error stream + exits 0 on
|
||||
that string.
|
||||
|
||||
5. **(state bucket is imported in step 4)** — listed here for sequence
|
||||
clarity; the S3 state bucket is the first import because the
|
||||
backend depends on it.
|
||||
|
||||
6. **Add new resources** that do not yet exist in the account:
|
||||
- KMS alias `alias/nova-oidc-signing` (§9, D-234).
|
||||
- Identity DynamoDB tables: `nova-users`, `nova-sessions`,
|
||||
`nova-pats` (§11).
|
||||
- JWKS Function URL with `AuthType: AWS_IAM` (NOT `NONE` — §10,
|
||||
INV-18, RESEARCH §4 critical pitfall).
|
||||
- CloudFront distribution + OAC + WAF WebACL + ACM certificate +
|
||||
Route53 alias (§14, REQ-364/365/366).
|
||||
|
||||
7. **`terraform plan`** — expect zero diff on the imported resources
|
||||
(they are already in their desired state) + a pure-add diff on the
|
||||
new resources. If the plan shows a diff on an imported resource,
|
||||
the import map or the Terraform resource block is wrong — fix
|
||||
before apply. **DRIFT-DETECT (REQ-356):** `terraform plan` exit 2
|
||||
(drift) fails the apply workflow; manual reconciliation required.
|
||||
|
||||
8. **HITL approval** — `terraform apply` against `main` requires a
|
||||
Gitea Actions approval from a user **distinct from the PR author**
|
||||
(TFM-HITL, REQ-357, INV-3). Self-approval is rejected:
|
||||
`gitea.triggering_actor == pull_request.user.login` → apply fails
|
||||
closed (M1.5 item 11).
|
||||
|
||||
9. **`terraform apply`** — on approval, the apply creates the new
|
||||
resources + adopts the imported ones. Smoke test (step 10) before
|
||||
declaring M1 done.
|
||||
|
||||
10. **Smoke test + CFN→Terraform conversion** — verify the live
|
||||
account is in the desired state (JWKS reachable via CloudFront,
|
||||
KMS round-trip, ABAC fail-closed). The CFN template in
|
||||
`acdl/acdl/nova/idp/setup.py` is archived to
|
||||
`docs/archive/nova-idp-cfn-v1.28.md` as read-only reference
|
||||
(REQ-369); the active path is now `terraform apply` in
|
||||
`nova-platform-ops`.
|
||||
|
||||
## 3. M1.5 verification gate (12-item spike)
|
||||
|
||||
The M1.5 gate is the 12-item spike from PLAN.md "Happy Path" (spec
|
||||
§3.3 Edge 5 items 1-8 + grill CF-1 items 9-12). **3 consecutive green
|
||||
rebuilds are required** in `nova-platform-ops` CI.
|
||||
|
||||
The 12 items:
|
||||
|
||||
1. `kj` v0.0.3 (pinned SHA in `platform/abac/kj-version.txt`) compiles
|
||||
with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64`.
|
||||
2. Resulting binary reports `file kj → ELF 64-bit LSB executable,
|
||||
x86-64, statically linked, no shared library` (KJ-STATIC).
|
||||
3. Container image built from
|
||||
`public.ecr.aws/lambda/python:3.12-al2023` with the binary copied
|
||||
to `/opt/kj/kj`, `chmod 0555`, owned by `sbx_user:1051`.
|
||||
4. Lambda runtime `python3.12` executes
|
||||
`nova_idp_token_vend.handler`; the handler invokes
|
||||
`subprocess.run(['/opt/kj/kj', 'apply', ...])` and parses stdout
|
||||
JSON.
|
||||
5. `tests/test_idp_auth.py` passes against the live image in moto-DDB.
|
||||
6. `tests/test_kms_roundtrip.py` passes against the live KMS key
|
||||
`alias/nova-oidc-signing` (REQ-362 path — covered-reference).
|
||||
7. End-to-end: known PAT → known ABAC-allowed action → signed OIDC
|
||||
token → `jose` verification → green. Known PAT + ABAC-denied action
|
||||
→ 403 with deny reason logged (INV-17 fail-closed).
|
||||
8. Image URI is recorded in Terraform state and in this operator
|
||||
guide (§18, REQ-371 Result row).
|
||||
9. **(grill CF-1) JWKS-EDGE-ONLY:** direct JWKS Function URL GET
|
||||
(bypassing CloudFront) returns **403**; via-CloudFront GET returns
|
||||
**200** (INV-18). Proves `AuthType: AWS_IAM` + OAC pinning.
|
||||
10. **(grill CF-1) IAM-NARROW:** `aws iam get-role-policy` on the
|
||||
Gitea OIDC role asserts no `Action: "*"` and no `Resource: "*"`
|
||||
(REQ-360).
|
||||
11. **(grill CF-1) TFM-HITL:** a `terraform apply`
|
||||
`workflow_dispatch` triggered by the PR author is **rejected**
|
||||
(exit non-zero); a dispatch by a distinct user proceeds (REQ-357).
|
||||
12. **(grill CF-1) rollback drill:** revert `nova_platform_version`
|
||||
pin → `terraform apply` → assert the prior ECR digest runs
|
||||
(D-236, guards against ECR tag mutability).
|
||||
|
||||
If items 1-7 fail three consecutive rebuilds, M2a activates (§5,
|
||||
REQ-363b Fargate toggle) with the same ECR image — no warmup hit
|
||||
because the standby is always running the same digest (KJ-LOCKSTEP).
|
||||
|
||||
### HARD P6 ship gate (grill CF-1/G-2.1)
|
||||
|
||||
P6 must not ship `v1.28.6` until the operator attests the M1.5 result
|
||||
in the row below. The operator fills this in **after** the gate passes
|
||||
3 consecutive green rebuilds in `nova-platform-ops` CI. P6 audit
|
||||
verifies the row exists + is non-empty.
|
||||
|
||||
#### M1.5 Verification Gate Result
|
||||
|
||||
| Rebuild # | Run ID / commit SHA | All 12 items green? | Attestor identity | Attested at (UTC) |
|
||||
|-----------|---------------------|---------------------|-------------------|-------------------|
|
||||
| 1 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
| 2 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
| 3 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
|
||||
> **P6 audit rule:** all three rows must be present, all 12 items
|
||||
> green on each, the three run IDs/SHAs distinct (consecutive
|
||||
> rebuilds, not one run copied thrice), and the attestor identity
|
||||
> non-empty. Empty or red → P6 blocks → escalate.
|
||||
|
||||
## 4. M2 operational handoff loop
|
||||
|
||||
M2 is the steady-state operational loop for rolling out an engineering
|
||||
change after M1.5 is green. The loop is the tag-pin bump → plan →
|
||||
HITL → apply cycle (D-235, D-238).
|
||||
|
||||
1. **Tag-pin bump** — the operator opens a PR in `nova-platform-ops`
|
||||
bumping `local.nova_platform_version` (e.g. `v1.29.3` → `v1.29.4`)
|
||||
+ `local.kj_source_sha` (the `kj` source SHA from
|
||||
`platform/abac/kj-version.txt` at the new tag). Both pins move
|
||||
together — there is one ECR image identity (KJ-LOCKSTEP, REQ-371).
|
||||
|
||||
2. **`terraform plan`** — CI runs `terraform plan` on the PR. The
|
||||
KJ-LOCKSTEP precondition (a `lifecycle.precondition` on both
|
||||
image-bearing resources — the Lambda `image_uri` and the Fargate
|
||||
task `container_definitions[0].image`) checks that both
|
||||
`image_uri` attributes resolve to the **same ECR digest** via
|
||||
`data.aws_ecr_image.kj_image`. If the two diverge, the plan fails
|
||||
closed — no second pipeline, no second SHA pin (D-238).
|
||||
|
||||
3. **HITL approval** — a Gitea Actions approver **distinct from the
|
||||
PR author** approves the apply (TFM-HITL, REQ-357). Self-approval
|
||||
is rejected (M1.5 item 11).
|
||||
|
||||
4. **`terraform apply`** — on approval, the apply updates both
|
||||
`aws_lambda_function.nova_idp_token_vend.image_uri` and
|
||||
`aws_ecs_task_definition.kj.container_definitions[0].image` to the
|
||||
same ECR digest. The Lambda image + the Fargate task redeploy to
|
||||
the same digest in one apply. Zero diff on KMS, DDB, IAM, edge
|
||||
(the only change is the image reference).
|
||||
|
||||
**Verification:** after the apply, `aws lambda get-function
|
||||
--function-name nova-idp-token-vend --query Configuration.Code.ImageUri`
|
||||
and `aws ecs describe-tasks` on the Fargate task both report the same
|
||||
digest. This is the M2 acceptance gate (PLAN §UX Acceptance Criteria
|
||||
3) + the REQ-371 Result row in §18.
|
||||
|
||||
## 5. M2a Fargate activation (conditional)
|
||||
|
||||
M2a activates **only if M1.5 fails 3 consecutive rebuilds** (D-236).
|
||||
It is the REQ-363b Fargate toggle — an always-warm minimal Fargate
|
||||
standby running the **same ECR image** as the Lambda (KJ-LOCKSTEP).
|
||||
|
||||
Because the standby is always running the same digest as the Lambda,
|
||||
activating M2a is **not** a warmup hit — the standby is already
|
||||
serving `GET /health → 200` every 10s (KJ-WARMUP-HEALTH, §15). The
|
||||
toggle repoints token-vend traffic from the Lambda to the Fargate
|
||||
task; no cold start, no image pull.
|
||||
|
||||
If both the Lambda path and the Fargate path fail (M1.5 items 1-7
|
||||
fail on both substrates), the operator escalates — Nova-idp ships in
|
||||
read-only partial mode (no token issuance) until `kj` is verified
|
||||
(Q7 carry-forward, spec §7.7).
|
||||
|
||||
**Fargate sunset discipline (D-237):** the standby (~$15-20/month,
|
||||
§7) may not be deleted unless REQ-363 has been green in production
|
||||
for **≥30 consecutive days**. Sunset requires an architecture review.
|
||||
See §15 for the health-check procedure.
|
||||
|
||||
## 6. Rollback procedure (D-236)
|
||||
|
||||
Rollback is a tag-pin revert — the same mechanism as the M2 rollout
|
||||
(§4), in reverse.
|
||||
|
||||
1. **Revert `nova_platform_version`** in `nova-platform-ops` to the
|
||||
prior tag (e.g. `v1.29.4` → `v1.29.3`). Open a PR, get HITL
|
||||
approval (TFM-HITL, same as rollout).
|
||||
|
||||
2. **`terraform apply`** — the apply reverts both the Lambda
|
||||
`image_uri` and the Fargate task `image` to the prior ECR digest.
|
||||
The prior tag's artifacts remain downloadable (GitHub Releases are
|
||||
append-only per tag, REQ-354 AC 2) — no artifact is re-built.
|
||||
|
||||
3. **Verify** the prior digest is running:
|
||||
```sh
|
||||
aws lambda get-function --function-name nova-idp-token-vend \
|
||||
--query Configuration.Code.ImageUri --output text
|
||||
# → <account>.dkr.ecr.us-east-1.amazonaws.com/nova-kj@sha256:<prior-digest>
|
||||
```
|
||||
|
||||
This is the M1.5 item 12 rollback drill + the operational rollback
|
||||
procedure. It guards against ECR tag mutability (RESEARCH §2) — the
|
||||
digest is immutable even if a tag is re-pushed.
|
||||
|
||||
## 7. Cost section
|
||||
|
||||
Monthly estimate for the `nova-platform-ops` live platform in account
|
||||
`581513795199` (pilot volume):
|
||||
|
||||
| Resource | Quantity | Est. monthly | Notes |
|
||||
|----------|----------|-------------|-------|
|
||||
| WAF WebACL (CloudFront-scoped) | 1 | ~$5-10 | + per-request; REQ-365 |
|
||||
| Fargate standby (0.25 vCPU, 512 MB) | 1 task | ~$15-20 | REQ-363b AC 4; largest line item |
|
||||
| KMS asymmetric key | 1 | ~$1 | `alias/nova-oidc-signing`, ECC_NIST_P256 |
|
||||
| DynamoDB (on-demand, 6 tables) | 6 | ~$2 | §11 tables |
|
||||
| Lambda invocations (3 Lambdas) | 3 | ~$2 | low pilot volume |
|
||||
| ECR image storage | ~100 MB | <$1 | the `kj` image |
|
||||
| S3 state bucket + access logs | 1 | <$1 | `nova-tfstate-*` |
|
||||
| CloudFront + ACM + Route53 | 1 distribution | ~$1 | ACM free for CloudFront-attached |
|
||||
| **Total** | | **~$30-40/month** | |
|
||||
|
||||
**Fargate standby is the largest line item** (~$15-20/month, REQ-363b
|
||||
AC 4). It is explicitly documented here with the D-237 sunset
|
||||
discipline (§5, §15): ≥30 consecutive days green before deletion +
|
||||
architecture review. Do not delete the standby to save ~$15/month
|
||||
without that review — it is the defensive fallback for the `kj`
|
||||
substrate.
|
||||
|
||||
## 8. Artifact-mirror fallback (Edge 6)
|
||||
|
||||
When the Gitea `act_runner` in `nova-platform-ops` CI cannot reach
|
||||
GitHub Releases (network partition, egress restriction, GitHub
|
||||
outage), the operator mirrors the artifact bundle locally by SHA-256.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. **Download the GitHub Release bundle** for the target tag
|
||||
(`v1.29.x`) from a machine that can reach GitHub Releases:
|
||||
```sh
|
||||
gh release download v1.29.0 \
|
||||
--repo continuous-intelligence/acdl \
|
||||
--pattern 'nova-lambda-token-vend-*.zip' \
|
||||
--pattern 'nova-cli-layer-*.zip' \
|
||||
--pattern 'nova-*-py3-none-any.whl' \
|
||||
--dir ./artifact-cache
|
||||
```
|
||||
|
||||
2. **Verify SHA-256** against the release body (each artifact's
|
||||
SHA-256 is listed in the GitHub Release body, REQ-354):
|
||||
```sh
|
||||
sha256sum ./artifact-cache/nova-lambda-token-vend-v1.29.0.zip
|
||||
# → must match the SHA-256 in the release body
|
||||
```
|
||||
|
||||
3. **Place the bundle in the operator's local artifact cache** — a
|
||||
directory the `act_runner` can read (e.g. a Gitea-lfs-tracked path
|
||||
in `nova-platform-ops`, or an S3 bucket the runner can reach).
|
||||
|
||||
4. **Reference by SHA-256 in the terraform variables** — the
|
||||
`nova-platform-ops` Terraform accepts an override for the artifact
|
||||
source: `nova_artifact_mirror_sha256 = "<sha256>"`. When set, the
|
||||
`data` sources resolve from the local cache by SHA-256 instead of
|
||||
from GitHub Releases. Unset → resume GitHub Releases resolution.
|
||||
|
||||
This fallback is for CI continuity only; the live `terraform apply`
|
||||
still resolves the ECR image by digest (KJ-LOCKSTEP), which is
|
||||
independent of GitHub Releases availability.
|
||||
|
||||
## 9. KMS rotation (D-234)
|
||||
|
||||
The OIDC signing key `alias/nova-oidc-signing` is provisioned with
|
||||
`KeySpec: ECC_NIST_P256`, `KeyUsage: SIGN_VERIFY`, on a **90-day
|
||||
rotation cadence** (matches per-stack CMK rotation per D-069).
|
||||
|
||||
**Verify the key spec + rotation status:**
|
||||
```sh
|
||||
aws kms describe-key --key-id alias/nova-oidc-signing \
|
||||
--query 'KeyMetadata.[KeySpec,KeyUsage,Description]' --output text
|
||||
# → ECC_NIST_P256 SIGN_VERIFY <description>
|
||||
```
|
||||
|
||||
**Apply a rotation policy** (key re-point, not key deletion — the
|
||||
alias moves to a new key while the old key stays valid during the
|
||||
overlap window, §17 JWKS-ROTATION):
|
||||
|
||||
1. Create the new key (same spec):
|
||||
```sh
|
||||
NEW_KEY=$(aws kms create-key \
|
||||
--key-spec ECC_NIST_P256 \
|
||||
--key-usage SIGN_VERIFY \
|
||||
--description "nova-oidc-signing-$(date +%Y%m%d)" \
|
||||
--query KeyId --output text)
|
||||
```
|
||||
|
||||
2. Re-point the alias:
|
||||
```sh
|
||||
aws kms update-alias --alias-name alias/nova-oidc-signing \
|
||||
--target-key-id "$NEW_KEY"
|
||||
```
|
||||
|
||||
3. Apply the rotation policy (the key policy grants `kms:Sign` to the
|
||||
token-vend Lambda role + `kms:GetPublicKey` to the JWKS Lambda
|
||||
role):
|
||||
```sh
|
||||
aws kms put-key-policy --key-id "$NEW_KEY" \
|
||||
--policy-name default --policy file://kms-signing-key-policy.json
|
||||
```
|
||||
|
||||
4. After the 24-hour overlap window (§17), disable + schedule deletion
|
||||
of the old key:
|
||||
```sh
|
||||
aws kms disable-key --key-id "<old-key-id>"
|
||||
aws kms schedule-key-deletion --key-id "<old-key-id>" \
|
||||
--pending-window-in-days 7
|
||||
```
|
||||
|
||||
5. Verify the new key is active: `nova idp setup --verify` (the KMS
|
||||
round-trip test, REQ-362).
|
||||
|
||||
**Audit:** emit a `kms.key_rotated` event with `old_key_id`,
|
||||
`new_key_id`, `rotated_at`.
|
||||
|
||||
## 10. JWKS reachability via CloudFront edge (D-233, INV-18)
|
||||
|
||||
The JWKS endpoint is the **only public read surface** of the live
|
||||
platform (INV-18, D-233). All other platform endpoints gate with
|
||||
`AuthType: AWS_IAM`. CloudFront + OAC pinning replaces direct Lambda
|
||||
Function URL exposure.
|
||||
|
||||
**Critical pitfall (RESEARCH §4):** the JWKS Function URL
|
||||
`AuthType` MUST be `AWS_IAM`, NOT `NONE`. A common mistake is to set
|
||||
`AuthType: NONE` on the Function URL (thinking CloudFront is the
|
||||
gate) — that exposes the JWKS endpoint directly to the internet,
|
||||
bypassing OAC. The correct configuration:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Function URL `AuthType` | `AWS_IAM` (NOT `NONE`) |
|
||||
| CloudFront OAC `OriginAccessControlOriginType` | `lambda` |
|
||||
| CloudFront OAC `SigningBehavior` | `always` |
|
||||
| Lambda resource policy | `lambda:InvokeFunctionUrl` scoped to the CloudFront distribution ARN |
|
||||
|
||||
With `AuthType: AWS_IAM` + OAC `always` signing, CloudFront signs
|
||||
every origin request with SigV4; a direct Function URL request has no
|
||||
SigV4 signature → 403. Only CloudFront can reach the origin.
|
||||
|
||||
**Verification (M1.5 item 9):**
|
||||
```sh
|
||||
# Via CloudFront → 200
|
||||
curl -sI https://<jwks-domain>/.well-known/jwks.json | head -1
|
||||
# → HTTP/2 200
|
||||
|
||||
# Direct Function URL → 403
|
||||
curl -sI "<raw-function-url>/.well-known/jwks.json" | head -1
|
||||
# → HTTP/2 403
|
||||
```
|
||||
|
||||
If the direct Function URL returns 200, the `AuthType` is `NONE` —
|
||||
fix the Terraform + re-apply before declaring M1.5 green.
|
||||
|
||||
## 11. PITR restore (data-engineer section)
|
||||
|
||||
DynamoDB point-in-time recovery (PITR) is enabled on every identity +
|
||||
contract table. PITR lets you restore a table to any second in the
|
||||
last **35 days** (the AWS retention window).
|
||||
|
||||
**Tables with PITR enabled:**
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `nova-contracts` | contract ingestor records |
|
||||
| `nova-change-requests` | change request ledger |
|
||||
| `nova-outbox` | audit outbox |
|
||||
| `nova-users` | Nova-idp users (Argon2id hashes) |
|
||||
| `nova-sessions` | Nova-idp sessions (TTL `expires_at`) |
|
||||
| `nova-pats` | Nova-idp PATs (revocation strong-read, D-229) |
|
||||
|
||||
**Enable PITR (on a new/restored table — PITR does not carry over
|
||||
from the source):**
|
||||
```sh
|
||||
aws dynamodb update-continuous-backups \
|
||||
--table-name <table> \
|
||||
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
|
||||
```
|
||||
|
||||
**Restore a table to a point in time** (PITR never overwrites the
|
||||
source — restore to a NEW table, then repoint):
|
||||
```sh
|
||||
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
aws dynamodb restore-table-to-point-in-time \
|
||||
--source-table-name <table> \
|
||||
--target-table-name <table>-restored \
|
||||
--restore-date-time "$RESTORE_TO" \
|
||||
--billing-mode-restore-as-is
|
||||
|
||||
# After the restore completes (status ACTIVE), repoint the app:
|
||||
# - update the stack env var to the restored table name, or
|
||||
# - rename: delete <table>, then update-table --new-table-name <table>
|
||||
# Then re-enable PITR on the restored table (see above).
|
||||
```
|
||||
|
||||
**Verify PITR is enabled on all tables:**
|
||||
```sh
|
||||
for t in nova-contracts nova-change-requests nova-outbox \
|
||||
nova-users nova-sessions nova-pats; do
|
||||
aws dynamodb describe-continuous-backups --table-name "$t" \
|
||||
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' \
|
||||
--output text
|
||||
done
|
||||
# → ENABLED (x6)
|
||||
```
|
||||
|
||||
Restores older than 35 days are impossible — for longer retention,
|
||||
export to S3 via the on-demand export or a scheduled AWS Backup plan.
|
||||
|
||||
## 12. DynamoDB import addresses (REQ-361, covered-reference)
|
||||
|
||||
The `importable-resources.tf` map in `nova-platform-ops` lists the
|
||||
existing live resources that `terraform import` adopts at M1 cutover
|
||||
(§2 step 4). Re-import exits non-zero with
|
||||
`resource_already_imported` (IMPORT-IDEMPOTENT); CI import treats this
|
||||
as idempotent success.
|
||||
|
||||
| Terraform address | AWS resource | Type |
|
||||
|-------------------|--------------|------|
|
||||
| `aws_s3_bucket.nova_tfstate` | `nova-tfstate-581513795199-us-east-1` | S3 bucket (state backend) |
|
||||
| `aws_dynamodb_table.nova_contracts` | `nova-contracts` | DynamoDB table |
|
||||
| `aws_dynamodb_table.nova_change_requests` | `nova-change-requests` | DynamoDB table |
|
||||
| `aws_dynamodb_table.nova_outbox` | `nova-outbox` | DynamoDB table |
|
||||
| `aws_iam_role.acdl_act_runner` | `acdl-act-runner-role` | IAM role (reused, spec §7.6) |
|
||||
| `aws_kms_key.<per_stack_cmk>` | per-stack CMKs | KMS key (one per stack) |
|
||||
|
||||
The identity tables (`nova-users`, `nova-sessions`, `nova-pats`) are
|
||||
**new** resources added at M1 (§2 step 6), not imported — they do
|
||||
not yet exist in the account at M1.
|
||||
|
||||
## 13. PAT revocation (D-229)
|
||||
|
||||
PAT revocation has a **60s SLO**: the token-vend Lambda does a
|
||||
strongly-consistent DynamoDB read (`ConsistentRead=True`) on every
|
||||
token-vend request. A revoked PAT is reflected on the next vend,
|
||||
within 60s P95.
|
||||
|
||||
**Verify a PAT's revocation status (strong read):**
|
||||
```sh
|
||||
aws dynamodb get-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<pat-id>"}}' \
|
||||
--consistent-read \
|
||||
--query 'Item.status.S' --output text
|
||||
# → active (still valid)
|
||||
# → revoked (next token-vend returns 403)
|
||||
```
|
||||
|
||||
**Revoke a PAT at the DDB level** (emergency — when the CLI is
|
||||
unavailable; the `jti` is known but the raw PAT is not):
|
||||
```sh
|
||||
aws dynamodb update-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<pat-id>"}}' \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
```
|
||||
|
||||
The item is **retained** (not deleted) so the audit trail is intact —
|
||||
only `status` flips from `active` to `revoked`. The next `token-vend`
|
||||
call with that `jti` returns `403 pat_revoked` immediately (D-229:
|
||||
the strong read is synchronous).
|
||||
|
||||
## 14. Edge configuration (REQ-364/365/366, covered-reference)
|
||||
|
||||
The edge stack fronts the JWKS Lambda with CloudFront + WAF + ACM +
|
||||
Route53. This is the public read surface (§10, INV-18).
|
||||
|
||||
### CloudFront + OAC (REQ-364)
|
||||
|
||||
- Distribution origin = the JWKS Lambda Function URL.
|
||||
- OAC: `OriginAccessControlOriginType: lambda`,
|
||||
`SigningBehavior: always` (§10).
|
||||
- Cache behavior: `Cache-Control: max-age=3600` honored (JWKS-SLO).
|
||||
|
||||
### WAF WebACL (REQ-365)
|
||||
|
||||
- Scope: `CLOUDFRONT` (the WebACL is in `us-east-1`, the only region
|
||||
for CloudFront-scoped WebACLs).
|
||||
- Rate-based rule: `RateBasedStatement` with `Limit: 3000`,
|
||||
`AggregateKeyType: IP`, `EvaluationWindowSec: 300` (3000 requests
|
||||
per 5 minutes per IP).
|
||||
- Managed rules: `AWSManagedRulesCommonRuleSet` (the AWS managed rule
|
||||
group for common attacks).
|
||||
|
||||
### ACM certificate (REQ-366)
|
||||
|
||||
- Certificate in `us-east-1` (CloudFront requires the cert in
|
||||
us-east-1).
|
||||
- DNS validation (a CNAME record per validation record is written to
|
||||
Route53). The cert status MUST be `ISSUED` (not
|
||||
`PENDING_VALIDATION`) before the CloudFront distribution can serve
|
||||
the domain.
|
||||
|
||||
### Route53 (REQ-366)
|
||||
|
||||
- An A-alias record pointing to the CloudFront distribution's domain
|
||||
name.
|
||||
|
||||
### `route53_record_not_resolvable` debugging
|
||||
|
||||
If the JWKS domain does not resolve (`route53_record_not_resolvable`
|
||||
or `NXDOMAIN`):
|
||||
|
||||
1. **Check ACM cert status:**
|
||||
```sh
|
||||
aws acm describe-certificate --certificate-arn <arn> \
|
||||
--query 'Certificate.Status' --output text
|
||||
# → must be ISSUED, not PENDING_VALIDATION
|
||||
```
|
||||
If `PENDING_VALIDATION`, the DNS validation CNAME records are not
|
||||
in Route53 (or not propagated). Re-apply the validation records +
|
||||
wait for AWS to validate (typically minutes).
|
||||
|
||||
2. **Check CloudFront status:**
|
||||
```sh
|
||||
aws cloudfront get-distribution --id <id> \
|
||||
--query 'Distribution.Status' --output text
|
||||
# → must be Deployed
|
||||
```
|
||||
If `InProgress`, wait for the deployment to finish. CloudFront
|
||||
deployments take ~5-15 minutes.
|
||||
|
||||
3. **Check the Route53 alias record** points to the CloudFront
|
||||
distribution domain name (not the Function URL).
|
||||
|
||||
## 15. Fargate standby health (KJ-WARMUP-HEALTH, REQ-363b)
|
||||
|
||||
The Fargate standby is the always-warm minimal defensive fallback
|
||||
(REQ-363b). It runs the **same ECR image** as the Lambda (KJ-LOCKSTEP,
|
||||
REQ-371) — so it is always running the current digest, never a stale
|
||||
one.
|
||||
|
||||
**Health probe:** `GET /health → 200` every **10s**
|
||||
(KJ-WARMUP-HEALTH).
|
||||
|
||||
**Failure handling:** 3 consecutive probe failures → alert + the
|
||||
token-vend path **fails closed** (no signing). The standby does not
|
||||
silently degrade — if it is not healthy, token-vend does not fall
|
||||
back to it; it fails closed (INV-17 ABAC discipline extended to the
|
||||
substrate).
|
||||
|
||||
**Verify the standby is `READY` before M1 cutover:**
|
||||
```sh
|
||||
# The Fargate task health check (target group)
|
||||
aws elbv2 describe-target-health \
|
||||
--target-group-arn <tg-arn> \
|
||||
--query 'TargetHealthDescriptions[0].TargetHealth.State' --output text
|
||||
# → healthy
|
||||
|
||||
# Direct probe
|
||||
curl -sI https://<fargate-endpoint>/health | head -1
|
||||
# → HTTP/1.1 200
|
||||
```
|
||||
|
||||
**Fargate sunset discipline (D-237):** the standby may not be deleted
|
||||
unless REQ-363 has been green in production for **≥30 consecutive
|
||||
days**. Sunset requires an architecture review. Do not delete the
|
||||
standby to save ~$15/month (§7) without that review — it is the
|
||||
defensive fallback for the `kj` substrate.
|
||||
|
||||
## 16. IAM scope (IAM-NARROW, REQ-360, covered-reference)
|
||||
|
||||
The Gitea OIDC role for `act_runner` (reused `acdl-act-runner-role`,
|
||||
spec §7.6) is bounded per REQ-360. **No `Action: "*"` or `Resource:
|
||||
"*"`** (IAM-NARROW).
|
||||
|
||||
The scope covers only:
|
||||
|
||||
| Action | Scope | Why |
|
||||
|--------|-------|-----|
|
||||
| `kms:*` | customer-managed keys in `581513795199` | KMS signing + rotation |
|
||||
| `dynamodb:*` | tables prefixed `nova-` | identity + contract tables |
|
||||
| `lambda:*` | functions prefixed `nova-` | the 3 Nova-idp Lambdas |
|
||||
| `s3:*` | buckets prefixed `nova-` | state bucket + artifact cache |
|
||||
| `cloudfront:*` | tagged resources | the JWKS distribution |
|
||||
| `wafv2:*` | tagged resources | the WebACL |
|
||||
| `acm:*` | tagged resources | the JWKS cert |
|
||||
| `route53:*` | tagged resources | the JWKS alias |
|
||||
| `iam:PassRole` | roles tagged `nova-ops-only` | pass roles to Lambda/ECS only |
|
||||
|
||||
**Verify (M1.5 item 10):**
|
||||
```sh
|
||||
aws iam get-role-policy --role-name acdl-act-runner-role \
|
||||
--policy-name <policy-name> --query 'PolicyDocument' --output json \
|
||||
| jq '.Statement[].Action, .Statement[].Resource'
|
||||
# → no "*" in either list
|
||||
```
|
||||
|
||||
If `Action: "*"` or `Resource: "*"` appears, the IAM policy is too
|
||||
broad — fix the Terraform + re-apply before declaring M1.5 green.
|
||||
|
||||
## 17. JWKS-ROTATION
|
||||
|
||||
On KMS key rotation (§9), **both old + new public keys** are
|
||||
published in the JWKS during a **24-hour overlap window**. The old
|
||||
key is removed from the JWKS only after consumers pick up the new
|
||||
one.
|
||||
|
||||
- During the overlap: the JWKS Lambda lists all keys the alias has
|
||||
pointed at that are still enabled. Already-issued OIDC tokens
|
||||
(signed with the old key) keep verifying until they expire (OIDC
|
||||
TTL default 15 min; PAT TTL ≤ 24h dev / ≤ 1h service-account).
|
||||
- **Do not disable the old key until at least the max PAT TTL (24h)
|
||||
has elapsed.**
|
||||
- After the overlap, the old key is removed from the JWKS + disabled +
|
||||
scheduled for deletion (§9 step 4).
|
||||
|
||||
This is JWKS-ROTATION (NFR) — the rotation is non-disruptive because
|
||||
consumers cache the JWKS for up to `max-age=3600` (1h, JWKS-SLO) and
|
||||
re-fetch within that window, picking up both keys during the overlap.
|
||||
|
||||
## 18. Cutover Gates (grill CF-2/G-5)
|
||||
|
||||
Each covered-reference REQ has a cutover gate (M1/M1.5/M2) with a
|
||||
verification command + a **"Result" column**. The operator fills the
|
||||
Result column after running the gate in `nova-platform-ops` CI.
|
||||
**P6 audit verifies every covered-reference REQ has a non-empty,
|
||||
green Result.** Empty or red → P6 blocks (grill CF-2/G-5).
|
||||
|
||||
| REQ | Gate | Verification command | Result |
|
||||
|-----|------|----------------------|--------|
|
||||
| REQ-355 | M1 | `terraform plan` resolves `data.aws_ecr_image.kj_image` from `local.nova_platform_version` + `local.kj_source_sha`; both image_uri attributes present | _(operator fills: green/red + run ID/SHA + attestor)_ |
|
||||
| REQ-356 | M1 | `terraform plan` exit 0 (no drift) on a clean checkout of `main`; exit 2 → `drift_detected` fails the apply workflow | _(operator fills)_ |
|
||||
| REQ-357 | M1.5 | `terraform apply` `workflow_dispatch` triggered by PR author → rejected; distinct user → proceeds (M1.5 item 11) | _(operator fills)_ |
|
||||
| REQ-358 | M2 | bump `nova_platform_version` → `terraform apply` → `aws lambda get-function ... ImageUri` `CodeSha256` matches the artifact SHA-256 from the GitHub Release body | _(operator fills)_ |
|
||||
| REQ-359 | M1 | `git -C nova-platform-ops remote -v` shows only the Gitea private remote (no GitHub mirror); Gitea repo `private: true` | _(operator fills)_ |
|
||||
| REQ-360 | M1.5 | `aws iam get-role-policy` on the OIDC role asserts no `Action: "*"` + no `Resource: "*"` (M1.5 item 10, §16) | _(operator fills)_ |
|
||||
| REQ-361 | M1 | `terraform import` on each address in `importable-resources.tf` (§12) succeeds; re-import exits `resource_already_imported` → CI treats as idempotent success (IMPORT-IDEMPOTENT) | _(operator fills)_ |
|
||||
| REQ-362 | M1.5 | `nova idp setup --verify` (KMS round-trip) against `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) → `{"passed":true}` (M1.5 item 6) | _(operator fills)_ |
|
||||
| REQ-363 | M1.5 | `nova_idp_token_vend.handler` invokes `subprocess.run(['/opt/kj/kj','apply',...])` on the live image; `file(1)` reports `statically linked` (M1.5 items 2-4, KJ-STATIC) | _(operator fills)_ |
|
||||
| REQ-363b | M1.5 | Fargate standby `GET /health → 200` every 10s (KJ-WARMUP-HEALTH); same ECR digest as the Lambda (KJ-LOCKSTEP); activates only if M1.5 items 1-7 fail 3× (§5) | _(operator fills)_ |
|
||||
| REQ-364 | M1.5 | direct JWKS Function URL → 403; via-CloudFront → 200 (M1.5 item 9, §10, INV-18) | _(operator fills)_ |
|
||||
| REQ-365 | M1 | `aws wafv2 get-web-acl` shows `RateBasedStatement` Limit 3000, AggregateKeyType IP, EvaluationWindowSec 300 + `AWSManagedRulesCommonRuleSet`; Scope CLOUDFRONT in us-east-1 (§14) | _(operator fills)_ |
|
||||
| REQ-366 | M1 | `aws acm describe-certificate` Status `ISSUED`; Route53 A-alias resolves to the CloudFront distribution domain (§14) | _(operator fills)_ |
|
||||
| REQ-371 | M2 | after `terraform apply`, both `aws_lambda_function.nova_idp_token_vend.image_uri` and `aws_ecs_task_definition.kj.container_definitions[0].image` report the same ECR digest (KJ-LOCKSTEP precondition green at plan) | _(operator fills)_ |
|
||||
|
||||
> **P6 audit rule (grill CF-2/G-5):** every row's Result column must
|
||||
> be non-empty + green. An empty or red Result blocks the milestone
|
||||
> ship. The operator attestation is the acdl-side evidence surface;
|
||||
> the live verification runs in `nova-platform-ops` CI.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — quick reference
|
||||
|
||||
| Procedure | Cadence / trigger | Section |
|
||||
|-----------|-------------------|---------|
|
||||
| Day-0 cutover (M1) | one-time | §2 |
|
||||
| M1.5 verification gate | one-time (3 consecutive green rebuilds) | §3 |
|
||||
| M2 operational handoff | per engineering change (tag-pin bump) | §4 |
|
||||
| M2a Fargate activation | conditional (M1.5 fails 3×) | §5 |
|
||||
| Rollback | on regression | §6 |
|
||||
| Artifact-mirror fallback | on GitHub Releases unreachable | §8 |
|
||||
| KMS rotation | every 90 days | §9 |
|
||||
| JWKS-ROTATION overlap | on each KMS rotation (24h window) | §17 |
|
||||
| PITR restore | on data loss / corruption (35-day window) | §11 |
|
||||
| Emergency PAT revocation | on compromise (DDB-level, immediate) | §13 |
|
||||
| Fargate standby health check | continuous (every 10s) | §15 |
|
||||
| Fargate sunset | ≥30 consecutive days green + architecture review | §5, §15 |
|
||||
| `route53_record_not_resolvable` debug | on JWKS domain not resolving | §14 |
|
||||
| Cutover gate attestation | at M1/M1.5/M2 (operator fills Result column) | §18 |
|
||||
@@ -0,0 +1,33 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart LR
|
||||
subgraph TODAY["Today — the gap is widening"]
|
||||
direction TB
|
||||
V["Delivery velocity<br/>↑ rising fast"]
|
||||
C["Coordination surface<br/>↑↑ rising faster"]
|
||||
V -.-|"gap"| C
|
||||
end
|
||||
subgraph COST["The cost of the gap"]
|
||||
direction TB
|
||||
R1["Remediation<br/>cycles"]
|
||||
R2["Gate fatigue<br/>& morale loss"]
|
||||
R3["Compliance<br/>found late"]
|
||||
end
|
||||
TODAY -->|"absorbs"| NOVA
|
||||
subgraph NOVA["Nova closes the gap"]
|
||||
direction TB
|
||||
N1["Owned primitives<br/>= no per-team infra authoring"]
|
||||
N2["Attested up front<br/>= no late compliance"]
|
||||
N3["Policy-bounded<br/>= no manual gating"]
|
||||
end
|
||||
COST -->|"absorbs"| NOVA
|
||||
style TODAY fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style COST fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style NOVA fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style V fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style C fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style R1 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style R2 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style R3 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style N1 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style N2 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style N3 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
@@ -0,0 +1,25 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart LR
|
||||
SDLC["SDLC<br/>code authored,<br/>built, tested"]
|
||||
PDLC["PDLC<br/>product backlog,<br/>release planning"]
|
||||
CONTRACT["The<br/>contract"]
|
||||
INGEST["Contract<br/>ingestor"]
|
||||
RESOLVE["Resolve<br/>→ L1/L2 stack"]
|
||||
POLICY["Policy +<br/>confidence<br/>enforcement"]
|
||||
APPLY["Reconcile<br/>→ live cloud"]
|
||||
PROD["Running,<br/>observable,<br/>attested infra"]
|
||||
SDLC --> CONTRACT
|
||||
PDLC --> CONTRACT
|
||||
CONTRACT -->|"validated<br/>boundary"| INGEST
|
||||
INGEST --> RESOLVE
|
||||
RESOLVE --> POLICY
|
||||
POLICY --> APPLY
|
||||
APPLY -->|"production-grade<br/>by construction"| PROD
|
||||
style SDLC fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style PDLC fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style CONTRACT fill:#fff,color:#D6002A,stroke:#D6002A
|
||||
style INGEST fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style RESOLVE fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style POLICY fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style APPLY fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style PROD fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
@@ -0,0 +1,22 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart TB
|
||||
ROOT["Two tenets<br/>discipline everything"]
|
||||
SB["Sovereign boundary<br/>govern the delivery lifecycle<br/>do not reach upstream"]
|
||||
LA["Lower autonomous<br/>higher attested<br/>compute: platform<br/>choice: human"]
|
||||
ROOT --> SB
|
||||
ROOT --> LA
|
||||
SB -->|"shapes"| FM["4-layer model<br/>artifact → contract →<br/>infra → runtime"]
|
||||
SB -->|"shapes"| CS["Contract schema<br/>the only integration<br/>surface to SDLC/PDLC"]
|
||||
SB -->|"shapes"| PE["Policy envelope<br/>confidence, NFRs,<br/>boundaries enforced"]
|
||||
LA -->|"shapes"| HITL["HITL gates<br/>named approver ≠ PR author<br/>at qa, prod, dr"]
|
||||
LA -->|"shapes"| CONF["Confidence signal<br/>0.50 → 0.75 → 0.90 → 0.95<br/>escalation below threshold"]
|
||||
LA -->|"shapes"| AUDIT["Audit lineage<br/>every action → one owner<br/>→ one evidence entry"]
|
||||
style ROOT fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style SB fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style LA fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style FM fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style CS fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style PE fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style HITL fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style CONF fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style AUDIT fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
@@ -0,0 +1,17 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart LR
|
||||
DEV["dev<br/>autonomous<br/>conf ≥ 0.50<br/>zero-touch"]
|
||||
QA["qa<br/>HITL gate<br/>conf ≥ 0.75<br/>named approver<br/>≠ PR author"]
|
||||
PROD["prod<br/>HITL gate<br/>conf ≥ 0.90<br/>named approver<br/>≠ PR author"]
|
||||
DRENV["dr<br/>HITL gate<br/>conf ≥ 0.95<br/>named approver<br/>≠ PR author"]
|
||||
DEV -->|"promotion"| QA
|
||||
QA -->|"promotion"| PROD
|
||||
PROD -->|"promotion"| DRENV
|
||||
ESC["Escalation:<br/>below threshold<br/>→ human judgment"] -.->|"triggers"| QA
|
||||
ESC -.->|"triggers"| PROD
|
||||
ESC -.->|"triggers"| DRENV
|
||||
style DEV fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style QA fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style PROD fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style DRENV fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style ESC fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
@@ -0,0 +1,29 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart LR
|
||||
subgraph ABOVE["Above the contract — upstream, not Nova"]
|
||||
APP["Application<br/>business logic"]
|
||||
IDE["IDE · sprint ·<br/>author workflows"]
|
||||
APPSEC["AppSec · dependency<br/>review · runtime security"]
|
||||
VM["VM · bare-metal ·<br/>OS lifecycles<br/>consumed, not maintained"]
|
||||
end
|
||||
CONTRACT["the contract<br/>━━━━━━━━━<br/>declared intent<br/>validated, published"]
|
||||
subgraph BELOW["Below the contract — Nova's lane"]
|
||||
L1["L1 primitives<br/>S3 · RDS · Lambda<br/>ECS · KMS · DynamoDB"]
|
||||
L2["L2 compositions<br/>microservice ·<br/>static-assets"]
|
||||
GUARD["Guardrails<br/>confidence · policy<br/>attestation · audit"]
|
||||
end
|
||||
RT["Production<br/>runtime"]
|
||||
ABOVE -.->|"declares via"| CONTRACT
|
||||
CONTRACT -->|"validated boundary"| BELOW
|
||||
BELOW -->|"reconciles to"| RT
|
||||
style ABOVE fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style BELOW fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style APP fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style IDE fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style APPSEC fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style VM fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style CONTRACT fill:#fff,color:#D6002A,stroke:#D6002A
|
||||
style L1 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style L2 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style GUARD fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style RT fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
@@ -0,0 +1,32 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart LR
|
||||
subgraph NOW["Now"]
|
||||
A["α — operating model<br/>named platform-ops body<br/>SLAs on every L2"]
|
||||
end
|
||||
subgraph Q1["Q1 2027"]
|
||||
B["β — infra observability<br/>auto-published on apply<br/>no per-team dashboards"]
|
||||
end
|
||||
subgraph Q2["Q2 2027"]
|
||||
G["γ — runbook generation<br/>from observed patterns<br/>SREs start, not blank"]
|
||||
end
|
||||
subgraph Q4["Q3-Q4 2027"]
|
||||
D["δ — audit ledger<br/>S3 Object Lock + JWS<br/>externally verifiable"]
|
||||
end
|
||||
A -->|"unlocks"| B
|
||||
B -->|"builds on"| G
|
||||
G -->|"matures to"| D
|
||||
NOW -.->|"governance<br/>is the unlock"| B
|
||||
subgraph UNCHANGED["Stays constant across the arc"]
|
||||
C["The contract surface<br/>remains the only<br/>SDLC/PDLC integration"]
|
||||
end
|
||||
D -.->|"exits to"| UNCHANGED
|
||||
style NOW fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style Q1 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style Q2 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style Q4 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style UNCHANGED fill:#fff,color:#1B1B1B,stroke:#D6002A
|
||||
style A fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style B fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style G fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style D fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style C fill:#fff,color:#1B1B1B,stroke:#D6002A
|
||||
@@ -0,0 +1,40 @@
|
||||
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1B1B1B','primaryBorderColor':'#D6002A','primaryTextColor':'#fff','secondaryColor':'#fff','secondaryBorderColor':'#D6002A','secondaryTextColor':'#1B1B1B','tertiaryColor':'#F0F0F0','lineColor':'#1B1B1B','fontFamily':'"Helvetica Neue","Arial",sans-serif'}}}%%
|
||||
flowchart TB
|
||||
subgraph PROVEN["Proven today — the foundation"]
|
||||
direction LR
|
||||
L1L2["13 L1 + 2 L2<br/>live in registry"]
|
||||
PILOT["Pilot live-apply<br/>confidence 0.800<br/>dev AWS account"]
|
||||
L1L2 --- PILOT
|
||||
end
|
||||
subgraph ARC["The runway arc — next steps"]
|
||||
direction LR
|
||||
N1["Ingest greenfield<br/>pilot projects"]
|
||||
N2["Promote<br/>dev → prod<br/>activate qa, prod, dr"]
|
||||
N3["Integrate SPGE<br/>constitutional library<br/>at the contract surface"]
|
||||
N4["Serve as the<br/>infrastructure layer<br/>beneath every app"]
|
||||
N1 --> N2 --> N3 --> N4
|
||||
end
|
||||
subgraph ASK["The ask"]
|
||||
ENDORSE["Architecture<br/>endorsement"]
|
||||
RUNWAY["Runway to next<br/>milestone by Nov 2026"]
|
||||
ENDORSE --- RUNWAY
|
||||
end
|
||||
subgraph RISK["If we don't — the structural risk"]
|
||||
GAP["Delivery acceleration<br/>vs operational absorption<br/>gap widens through 2027"]
|
||||
end
|
||||
PROVEN -->|"foundation enables"| ARC
|
||||
ARC -->|"lands at"| ASK
|
||||
ASK -.->|"without it"| RISK
|
||||
style PROVEN fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style ARC fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style ASK fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style RISK fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style L1L2 fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style PILOT fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style N1 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style N2 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style N3 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style N4 fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
style ENDORSE fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style RUNWAY fill:#1B1B1B,color:#fff,stroke:#D6002A
|
||||
style GAP fill:#F0F0F0,color:#1B1B1B,stroke:#D6002A
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
REQ-372 — Nova Leadership Presentation Deck (v1.30, single-shot artifact).
|
||||
|
||||
This deck is the leadership artifact for Infrastructure & Operations
|
||||
(CTO + VP Technology + Product Management), presented live in
|
||||
August 2026, securing architecture endorsement and a November 2026
|
||||
runway to demonstrate Nova's next milestone.
|
||||
|
||||
Related-but-distinct artifact: nova-autonomous-cloud-delivery-marp.md
|
||||
(the citizen-developer pitch). This deck does NOT compress or modify
|
||||
that artifact — the two decks remain discrete (D-241). The existing
|
||||
citizen-developer deck is untouched.
|
||||
|
||||
Vision grounding: [1] citations in slides 3, 5, 7 speaker notes
|
||||
resolve to docs/vision.md (the spec's acdl-vision.md reference).
|
||||
|
||||
Diagrams: 7 S&P-themed mermaid diagrams (D-246), one per slide,
|
||||
rendered via scripts/render_leadership_diagrams.sh (mmdc + vendored
|
||||
Chromium libs). Sources: assets/mmd/leadership-slide-N.mmd.
|
||||
PNGs: assets/png/leadership-slide-N.png.
|
||||
|
||||
Render: python3 scripts/render_pptx.py docs/presentations/nova-leadership-deck-marp.md \
|
||||
--output docs/presentations/nova-leadership-deck.pptx
|
||||
Smoke test: bash scripts/check_leadership_deck.sh
|
||||
-->
|
||||
---
|
||||
marp: true
|
||||
theme: default
|
||||
footer: "Nova Platform - Infrastructure & Operations"
|
||||
paginate: false
|
||||
size: 16x9
|
||||
style: |
|
||||
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; }
|
||||
h3 { color: #D6002A; font-size: 22px; margin-bottom: 0.2em; }
|
||||
table { font-size: 18px; width: 100%; border-collapse: collapse; }
|
||||
th { background: #F0F0F0; border-bottom: 2px solid #D6002A; padding: 4px 8px; text-align: left; }
|
||||
td { border-bottom: 1px solid #F0F0F0; padding: 4px 8px; }
|
||||
blockquote { border-left: 4px solid #D6002A; color: #1B1B1B; font-size: 20px; padding-left: 12px; }
|
||||
pre { background: #1B1B1B; color: #FFFFFF; border-radius: 4px; padding: 12px; font-size: 16px; }
|
||||
code { background: #F0F0F0; color: #1B1B1B; border-radius: 2px; padding: 1px 4px; font-size: 18px; }
|
||||
pre code { background: transparent; color: #FFFFFF; }
|
||||
img { display: block; margin: 0 auto; max-width: 100%; max-height: 380px; object-fit: contain; }
|
||||
strong { color: #D6002A; }
|
||||
.benefit { margin-top: 0.6em; padding-top: 0.4em; border-top: 1px solid #D6002A; color: #1B1B1B; font-size: 20px; font-style: italic; }
|
||||
@media print { section { overflow: hidden; } }
|
||||
---
|
||||
|
||||
<!-- _class: title -->
|
||||
<!-- _paginate: false -->
|
||||
|
||||
# The friction every delivery team lives today
|
||||
|
||||
> *Velocity is up; the coordination surface around each change is up faster.*
|
||||
|
||||
- → Infrastructure is authored by people who don't specialize in infrastructure — the platform team is not standing behind every bucket, every instance, every key.
|
||||
- → Every change is gated because one misconfiguration can expose the entire estate — so every change is reviewed, every change is manual, and the cost of getting it wrong is account-wide.
|
||||
- → Compliance, security, and NFRs are checked late — after the PR, after the merge, sometimes after the deploy — fueling remediation cycles that erode delivery cadence and team morale.
|
||||
|
||||
> *Nova absorbs all three — owned building blocks, separation of concerns, attested compliance up front.*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
Three patterns drive the friction every delivery team lives today, and all three trace back to one binding constraint: software delivery scales with the coordination surface around it, not the engineering inside it [1]. That is the claim the vision document opens with, and it is the lens for everything Nova does.
|
||||
|
||||
The first pattern: infrastructure is authored by people who do not specialize in infrastructure. The platform team is not standing behind every S3 bucket, every RDS instance, every KMS key. The application team is. They are smart, they are capable, but infrastructure is not their craft, and the long tail of well-meaning services shows it.
|
||||
|
||||
The second pattern: every change is gated because one misconfiguration can expose the entire estate. A single bucket without SSE-KMS, a single RDS without deletion protection, a single Lambda with an over-privileged role — and the blast radius is the whole account. So every change is reviewed, every change is gated, and the gate is manual because the cost of getting it wrong is account-wide.
|
||||
|
||||
The third pattern: compliance, security, and NFRs are checked late. They are checked after the PR, after the merge, sometimes after the deploy. By then remediation is a cycle — it erodes delivery cadence and it erodes morale.
|
||||
|
||||
Nova absorbs all three. Owned building blocks, separation of concerns, attested compliance up front. And one distinction that matters for this room: Nova's lane is infrastructure patching. AppSec — dependency review, runtime application security, the application-layer threat model — stays with the application team. Nova is not a remediation tool. Nova is not a security blanket. Nova is the infrastructure beneath the application, owned by the platform, attested before the consumer ever touches it.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Nova in one frame
|
||||
|
||||
> *You already recognize this pattern.*
|
||||
|
||||
Every Central IT team curates a golden image for Windows, for Linux, for macOS. They own it, they patch it, they ship it — and consumers consume it without thinking about what is inside. That trade — per-application control for uniform operability — is one every enterprise has already made at the OS layer.
|
||||
|
||||
Nova plays the same role one layer up: for everything that runs your cloud. S3 buckets with SSE-KMS posture. RDS instances with deletion protection and PITR. Lambda containers with static ABAC binaries. ALBs, ECS services, KMS keys, DynamoDB tables. Each primitive is owned by the platform team, patched by the platform team, attested by the platform team, and consumed by anyone who declares a contract.
|
||||
|
||||
The difference is rigor: every primitive is versioned, tested across its entire lifecycle, and bounded by policy before any consumer ever touches it.
|
||||
|
||||
> *Nova's lane is the infrastructure beneath the application. AppSec stays with the application team.*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
The Central IT golden-image pattern is one every leadership team already recognizes. Central IT curates the Windows image, the Linux image, the macOS image. They own it, they patch it, they ship it, and consumers consume it without thinking about what is inside. That trade — per-application control for uniform operability — is a trade every enterprise has already made at the OS layer.
|
||||
|
||||
Nova plays the same role one layer up. Not the OS image, but everything that runs your cloud: S3 buckets with SSE-KMS posture, RDS instances with deletion protection and PITR, Lambda containers with static ABAC binaries, ALBs, ECS services, KMS keys, DynamoDB tables. Each primitive is owned by the platform team, patched by the platform team, attested by the platform team, and consumed by anyone who declares a contract. The platform begins where the artifact is compiled and ends where it runs in production under operational guardrails [1]. That boundary is the sovereignty claim — Nova governs the delivery lifecycle, not the upstream product or software development lifecycle.
|
||||
|
||||
The difference from Central IT is rigor: every primitive is versioned, tested across its entire lifecycle, and bounded by policy before any consumer ever touches it. The sovereignty-via-boundary argument is not defensive. It is the same argument Central IT already won at the OS layer: the platform owns the primitive so the consumer does not have to.
|
||||
|
||||
And the lane stays narrow. Nova's lane is the infrastructure beneath the application. AppSec, dependency review, and runtime application security stay where they have always been — with the application team.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Two principles that organize everything else
|
||||
|
||||
> *Two tenets discipline every other decision.*
|
||||
|
||||
**Sovereign boundary** — Nova governs the delivery lifecycle; it does not reach upstream into product or software development [1]. Integration with SDLC and PDLC partners — the IDE, the sprint tool, the author workflow, the agent harness — happens exclusively through the validated, published contract surface. What lives outside the contract is not Nova's domain.
|
||||
|
||||
**Lower autonomous · higher attested** — lower environments proceed through agentic, zero-touch automation; promotion to qa, prod, and dr requires deliberate human attestation [1]. Not a rubber stamp — a policy-mandated act of accountability by a named human distinct from the PR author. The compute the platform makes; the choice the human keeps.
|
||||
|
||||
> *Everything else in the architecture inherits from these two.*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
The architecture is principled, not improvised. Two tenets discipline every other decision the platform makes, and both come straight from the vision document [1].
|
||||
|
||||
The first tenet is the sovereign boundary. Nova governs the delivery lifecycle. It does not reach upstream into product or software development. Integration with SDLC and PDLC partners — the IDE, the sprint tool, the author workflow, the agent harness — happens exclusively through the validated, published contract surface. What lives outside the contract is not Nova's domain. This is not a defensive posture. It is an operating principle: the platform owns its lane, the upstream owns its lane, and the contract is where they meet. The four-layer model, the contract schema, the policy envelope — all of it inherits from this tenet.
|
||||
|
||||
The second tenet is lower autonomous, higher attested. Lower environments proceed through agentic automation — zero-touch, the platform reconciles. Promotion to higher environments — qa, prod, dr — requires deliberate human attestation. Not a rubber stamp. Not a courtesy notification. A policy-mandated act of accountability by a named human distinct from the PR author. The compute the platform makes; the choice the human keeps. The HITL gates, the confidence threshold, the escalation paths — all of it inherits from this tenet.
|
||||
|
||||
And the point for this room: these two tenets are not aspirational. They are load-bearing. Every other architectural decision — the four-layer model, the contract schema, the policy envelope, the audit lineage, the confidence signal — inherits from these two. If you endorse the architecture, you are endorsing these two tenets and everything that flows from them. The next slide is what the line looks like in 18 months of milestones.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Live · Attested · Stays human
|
||||
|
||||
**Live today** — 41 capabilities across 12 domains; pilot confidence 0.800. The contract ingestor, the audit outbox, the state buckets, and the live pilot run have operated in our AWS estate since v1.7. DORA, adoption, and policy-conformance metrics flow to PowerBI from the same audit stream as the lineage — one pane, no second source of truth. Every finding carries one owner, one patch state, one audit entry.
|
||||
|
||||
**Attested on promotion** — qa, prod, and dr require a named human approver distinct from the PR author. Rubber stamps cannot be silently issued; the attestation is a policy-mandated act of accountability.
|
||||
|
||||
**Stays human — by design** — confidence below the autonomy threshold at qa, prod, or dr triggers human escalation [1]. Some categories of decision are preserved for human judgment, and the platform says so out loud.
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
Three columns, three claims, one disambiguation. The claims are real, observable, and disciplined — and the distinction matters for this room.
|
||||
|
||||
Real: 41 capabilities across 12 domains are live today. The contract ingestor, the audit outbox, the state buckets, and the live pilot run have been operating in our AWS estate since v1.7. Pilot evidence at v1.26 returned confidence 0.800 — that is a measured, recorded number, not a forecast. DORA, adoption, and policy-conformance metrics flow to PowerBI from the same audit stream as the lineage. One pane, no second source of truth. Every finding carries one owner, one patch state, one audit entry. A POC is production-grade by construction — there is no "POC that became prod" surprise, because the platform enforces production-grade posture from the first apply [1].
|
||||
|
||||
Observable: the audit lineage is the single pane. DORA, adoption, policy-conformance — all from the same stream. That is not three dashboards stitched together. It is one stream, one schema, one owner per finding. The POC-to-prod discipline [1] is the same claim from the other direction: the platform does not have a "POC mode" that gets quietly upgraded to "prod mode." Production-grade is the default.
|
||||
|
||||
Disciplined: attested on promotion, stays human by design. qa, prod, and dr require a named human approver distinct from the PR author. Rubber stamps cannot be silently issued. Confidence below the autonomy threshold at qa, prod, or dr triggers human escalation [1]. Some categories of decision are preserved for human judgment, and the platform says so out loud. That is the HITL discipline closing [1]: the platform makes the compute, the human keeps the choice, and the boundary is policy-mandated, not discretionary.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## The boundary keeps us honest
|
||||
|
||||
> *Nova stays where it belongs.*
|
||||
|
||||
**In Nova's lane**
|
||||
|
||||
- → Infrastructure primitives: S3, RDS, Lambda, ECS, DynamoDB, KMS, CloudFront.
|
||||
- → Operational guardrails: confidence, policy, attestation, audit lineage.
|
||||
- → CVE response at the infrastructure layer.
|
||||
|
||||
**Outside Nova's lane**
|
||||
|
||||
- → Application business logic.
|
||||
- → IDE, sprint, and author workflows [1].
|
||||
- → Application-layer security: AppSec, dependency review, runtime threat modeling.
|
||||
- → VM, bare-metal, and OS lifecycles [1].
|
||||
|
||||
> *The line is the contract. Everything below it is Nova. Everything above it stays where it has always been.*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
The boundary is not a defensive posture. It is an operating principle — and it is the principle that keeps the architecture honest [1].
|
||||
|
||||
In Nova's lane: infrastructure primitives. S3, RDS, Lambda, ECS, DynamoDB, KMS, CloudFront. Operational guardrails — confidence, policy, attestation, audit lineage. CVE response at the infrastructure layer. These are the things the platform owns, the things the platform patches, the things the platform attests. The consumer declares intent; the platform delivers safe production deployment.
|
||||
|
||||
Outside Nova's lane: application business logic. The IDE, the sprint, the author workflow [1] — those are upstream of the contract, and the platform does not reach into them. Application-layer security — AppSec, dependency review, runtime threat modeling — stays with the application team. That is not a gap. It is an autonomy-preserving design choice: the application team owns their lane, the platform owns its lane, and the contract is where they meet. VM, bare-metal, OS lifecycles [1] — the vision document is explicit: infrastructure is consumed, not maintained. Compute is abstract, containerized, or serverless. The platform does not manage node-level or OS-level lifecycles.
|
||||
|
||||
The line is the contract. Everything below the contract is Nova. Everything above it stays where it has always been. That is the boundary discipline, and it is the discipline that lets the platform scale without becoming the application team's bottleneck — and lets the application team scale without becoming the platform's risk. The boundary is what makes the ask small and the return large: the platform owns its surface, the consumer owns theirs, and neither side silently absorbs the other's burden.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## The 18-month shape
|
||||
|
||||
> *Where CDLC meets SDLC + PDLC — through the contract surface, not above it.*
|
||||
|
||||
**α (now → Q4'26) — Operating model + federated governance.** A named platform-ops body owns the platform; SLAs on every L2 are ratifiable by platform and consumer. The operating model is published; integration surfaces for SDLC and PDLC harnesses are documented at the contract boundary.
|
||||
|
||||
**β (Q1'27) — Auto-published infra observability.** Every consumer stack ships with CloudWatch dashboards, uptime-kuma monitors, and alert routing on apply — infrastructure observability as a property, no per-team authoring required.
|
||||
|
||||
**γ (Q2'27) — Runbook generation from telemetry.** Every L1 primitive ships with an auto-generated incident runbook derived from observed patterns. SREs get a starting runbook, not a blank page.
|
||||
|
||||
**δ (Q3'27 → Q4'27) — Audit ledger, tamper-resistant.** The SQLite hash-evidence stream migrates to S3 Object Lock + JWS signatures. External counsel verifies any production change back to a named human attestation.
|
||||
|
||||
> *Nova absorbs no IDE, no editor, no sprint tool, no agent harness.*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
The 18-month shape is a boundary-respecting integration arc, not an expansion arc. Four milestones, each disciplined by the sovereign-boundary tenet [1]: Nova meets SDLC and PDLC through the contract surface, not above it.
|
||||
|
||||
Alpha, now through Q4 2026, is the operating model and federated governance. A named platform-ops body owns the platform. SLAs on every L2 are ratifiable by platform and consumer. The operating model is published. Integration surfaces for SDLC and PDLC harnesses are documented at the contract boundary [1] — that is the unlock. The platform publishes the contract; the upstream tool integrates against it.
|
||||
|
||||
Beta, Q1 2027, is auto-published infra observability. Every consumer stack ships with CloudWatch dashboards, uptime-kuma monitors, and alert routing on apply. The infra-vs-app observability discipline [1]: the platform publishes infrastructure observability. Application observability stays with the app team.
|
||||
|
||||
Gamma, Q2 2027, is runbook generation from telemetry. Every L1 primitive ships with an auto-generated incident runbook derived from observed patterns. SREs get a starting runbook, not a blank page. The infra-vs-app runbook discipline [1]: the platform generates the infrastructure runbook. The application runbook stays with the app team.
|
||||
|
||||
Delta, Q3 through Q4 2027, is the audit ledger, tamper-resistant and externally addressable. The SQLite hash-evidence stream migrates to S3 Object Lock plus JWS signatures. External counsel verifies any production change back to a named human attestation — audit lineage outward [1], not upstream. The ledger is the proof, the attestation is the name, and the boundary holds.
|
||||
|
||||
Nova absorbs no IDE, no editor, no sprint tool, no agent harness. The contract surface is where CDLC meets SDLC and PDLC.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## What we ask · What comes back
|
||||
|
||||
**What works now.** Deploying L1 & L2 stacks works today in the sandbox AWS account — 13 L1 primitives and 2 L2 modules, live-applied at confidence 0.800. Next steps: ingest greenfield pilot projects, promote from sandbox to production, integrate with the SPGE constitutional library, and serve as the infrastructure layer.
|
||||
|
||||
**What we ask.** Architecture endorsement. Runway to the next milestone.
|
||||
|
||||
**Why now.** Agentic SDLC is reshaping the delivery curve. What is barely keepable today — incident response, compliance reconciliation, security remediation — does not compress at the same rate as the velocity it has to keep pace with. By the end of 2027, the gap between delivery acceleration and operational absorption is the structural risk.
|
||||
|
||||
**What comes back.** The infrastructure foundation that absorbs the velocity. Metrics that tell us where to push next. Audit lineage that closes the regulatory question. The next milestone, **by November 2026**.
|
||||
|
||||
> *What we do not ask for: an IDE, a sprint tool, an author workflow, an upstream pipeline. Nova stays in its lane [1].*
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
This is presented to Infrastructure and Operations leadership in August 2026. What works now: deploying L1 and L2 stacks works today. 13 L1 primitives and 2 L2 modules are live in the registry; the pilot ran a live terraform apply against the dev AWS account at v1.26 and returned confidence 0.800 — a measured number, not a forecast. We refer to the dev account colloquially as the sandbox. Next steps: ingest greenfield pilot projects (greenfield consumers with no legacy to reconcile), promote from sandbox to production (the promotion path with HITL gates exists; the pilot was dev-only, so activating qa, prod, dr is the next step), integrate with the SPGE constitutional library (an external governance system the audience recognizes; the platform meets it at the contract surface), and serve as the infrastructure layer.
|
||||
|
||||
The ask is architecture endorsement and runway to the next milestone by November 2026. Why now: agentic SDLC is reshaping the delivery curve. What is barely keepable today — incident response, compliance reconciliation, security remediation — does not compress at the same rate as the velocity. By end of 2027, the gap between delivery acceleration and operational absorption is the structural risk. Internal directional target: a 60% reduction in that gap — not a sourced claim, a directional target the platform's metrics will track.
|
||||
|
||||
What comes back: the infrastructure foundation that absorbs the velocity, metrics that tell us where to push next, audit lineage that closes the regulatory question, and the next milestone by November 2026. Not a budget, not a headcount — runway to land the architecture endorsement. What we do not ask for: an IDE, a sprint tool, an author workflow, an upstream pipeline. Nova stays in its lane [1]. The sovereign boundary makes the ask small and the return large.
|
||||
-->
|
||||
@@ -0,0 +1,408 @@
|
||||
# Nova Identity Layer — Threat Model
|
||||
|
||||
> **REQ-347** — identity-layer threat model. Covers the 8 threats
|
||||
> enumerated below + the **C-9.2 INV-18..21 compression audit**. The
|
||||
> C-6.2 grill additions (JWKS DDoS, PAT max TTL, ABAC fail-closed) are
|
||||
> integrated into the threat list, not appended.
|
||||
>
|
||||
> Scope: the Nova-idp identity layer (`nova-idp-auth` +
|
||||
> `nova-idp-token-vend` + `nova-idp-jwks` Lambdas, the KMS signing key,
|
||||
> the 4 DynamoDB tables, the `nova auth` CLI, the PAT lifecycle). Out
|
||||
> of scope: the downstream contract resolver, Terraform adapter, and
|
||||
> consumer-side auth (those have their own threat models).
|
||||
|
||||
## 1. Assets
|
||||
|
||||
| Asset | Where | Sensitivity |
|
||||
|-------|-------|-------------|
|
||||
| User passwords | `nova-users.password_hash` (Argon2id) | high — hash only; raw never stored |
|
||||
| PATs (personal access tokens) | `nova-pats` (hash only) + returned to caller once | high — bearer token, ≤24h/≤1h TTL |
|
||||
| OIDC tokens | `~/.nova/credentials.json` (0600) + in-flight to clients | medium — short-lived (15 min default) |
|
||||
| KMS signing key | KMS `alias/nova-oidc-signing` (`ECC_NIST_P256`) | high — the trust anchor for all OIDC tokens |
|
||||
| ABAC policy | `platform/abac/token-vend.policy` (git-tracked) | high — the authorization rules |
|
||||
| DynamoDB tables | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` | high — the identity store |
|
||||
| JWKS endpoint | `nova-idp-jwks` function URL (`AuthType: NONE`) | medium — public, must be available but is not secret |
|
||||
| Audit stream | stderr JSON from each Lambda + the CLI | high — tamper-evidence for the whole layer |
|
||||
|
||||
## 2. Trust boundaries
|
||||
|
||||
```
|
||||
┌────────────────┐ IAM-auth function URL ┌────────────────────┐
|
||||
│ Developer CI │ ───────────────────────────► │ nova-idp-auth │
|
||||
│ (nova CLI) │ │ nova-idp-token-vend│
|
||||
│ │ ◄────── OIDC token ───────── │ (KMS sign) │
|
||||
└────────┬───────┘ └─────────┬──────────┘
|
||||
│ │
|
||||
│ ~/.nova/credentials.json (0600) │ strong-read GetItem
|
||||
│ NOT the raw PAT ▼
|
||||
│ ┌────────────────────┐
|
||||
│ │ nova-pats (DDB) │
|
||||
│ │ nova-users/sessions│
|
||||
│ JWKS fetch (unauthenticated) └────────────────────┘
|
||||
│ ──────────────────────────────────► ┌────────────────────┐
|
||||
│ │ nova-idp-jwks │
|
||||
│ ◄──── public key (JWK) ──────────── │ (AuthType: NONE) │
|
||||
▼ └────────────────────┘
|
||||
┌────────────────┐
|
||||
│ AWS KMS │ kms:Sign (token-vend role only)
|
||||
│ alias/nova- │ kms:GetPublicKey (jwks role)
|
||||
│ oidc-signing │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
The key boundary crossings:
|
||||
1. **Internet → JWKS Lambda** (unauthenticated function URL) — the
|
||||
DDoS surface (Threat T-4).
|
||||
2. **CLI → auth/token-vend Lambdas** (IAM-authenticated function URLs)
|
||||
— the credential-injection surface.
|
||||
3. **token-vend Lambda → KMS** (`kms:Sign`) — the key-use surface.
|
||||
4. **token-vend Lambda → DDB** (strong read on `nova-pats`) — the
|
||||
revocation surface.
|
||||
|
||||
## 3. Threats + mitigations
|
||||
|
||||
### T-1 — Password compromise (storage)
|
||||
|
||||
**Threat:** an attacker with read access to `nova-users` (DDB export,
|
||||
backup, a leaked snapshot) recovers plaintext passwords.
|
||||
|
||||
**Mitigations:**
|
||||
- **Argon2id hashing** with OWASP-minimum parameters
|
||||
(`time_cost=3, memory_cost=65536 KiB, parallelism=1`) —
|
||||
`core/lambda/nova_idp_auth.py:hash_password`. Argon2id is the
|
||||
recommended PHC winner; the parameters are the OWASP minimum (C-7.2).
|
||||
- **Fail-closed on Argon2 unavailable** (D-228): if the `argon2-cffi`
|
||||
C extension fails to import, `_ARGON2_AVAILABLE` is `False` and
|
||||
`hash_password`/`verify_password` raise `Argon2UnavailableError` →
|
||||
the handler returns **503**. **No pure-Python fallback, no weak
|
||||
hash, no crash.** Verified by `tests/test_argon2_fail_closed.py`.
|
||||
- **No raw passwords anywhere** (INV-16): the handler never logs the
|
||||
password argument; the audit scrubber (`_emit_audit`) pops any
|
||||
`password`/`new_password`/`old_password` kwarg defense-in-depth;
|
||||
the DDB item has `password_hash`, never `password`. Verified by
|
||||
`tests/test_idp_auth.py:TestNoRawPasswordsInLogs`.
|
||||
|
||||
**Residual risk:** low. Argon2id with the OWASP params is
|
||||
GPU-resistant at scale; the remaining risk is a parameter-weakness
|
||||
advisory (mitigated by the 90-day KMS rotation cadence's analog for
|
||||
hash params — revisit annually).
|
||||
|
||||
### T-2 — PAT theft + max TTL (C-6.2)
|
||||
|
||||
**Threat:** an attacker exfiltrates a PAT (filesystem read of
|
||||
`~/.nova/credentials.json`, a leaked CI env var, a phishing capture)
|
||||
and uses it to vend OIDC tokens until it expires.
|
||||
|
||||
**Mitigations:**
|
||||
- **`~/.nova/credentials.json` stores the OIDC token + PAT metadata
|
||||
(`jti`, `exp`, `type`) ONLY — NOT the raw PAT** (C-7.3). The raw PAT
|
||||
is entered once at `nova auth login` and never persisted. An attacker
|
||||
who reads the credentials file gets a short-lived OIDC token (15 min
|
||||
default), not the long-lived PAT. Verified by
|
||||
`tests/test_auth_commands.py:test_login_stores_oidc_token_not_raw_pat`.
|
||||
- **Max TTL (C-6.2):** developer PATs ≤ 24h (86400s), service-account
|
||||
PATs ≤ 1h (3600s). Enforced in `core.pat_lifecycle.issue_pat` —
|
||||
requests above the max are clamped (with an audit event). The shorter
|
||||
service-account TTL bounds the CI blast radius.
|
||||
- **Revocation via strong-read DDB (D-229):** the token-vend Lambda
|
||||
does `GetItem(PK=jti, ConsistentRead=True)` on `nova-pats` on every
|
||||
vend. A revocation (`status=revoked`) is reflected on the next vend
|
||||
within **60s P95** (the strong read is synchronous — the 60s is the
|
||||
P95 propagation bound, not a polling delay). Verified by
|
||||
`tests/test_pat_revocation.py:test_pat_revocation_slo` (asserts
|
||||
`<1s` locally).
|
||||
- **Emergency revocation at the DDB level** (when the CLI is
|
||||
unavailable): `aws dynamodb update-item --table-name nova-pats ...`
|
||||
flips `status` to `revoked` — see `docs/operator-guide-idp.md` §9.
|
||||
|
||||
**Residual risk:** medium. The PAT is a bearer token — theft is
|
||||
undetectable until the attacker vends a token. Mitigation is TTL
|
||||
bounding + revocation, not prevention. The 1h service-account cap is
|
||||
the primary control for CI exposure.
|
||||
|
||||
### T-3 — JWKS unauthenticated endpoint DDoS (C-6.2)
|
||||
|
||||
**Threat:** the JWKS endpoint (`nova-idp-jwks` function URL,
|
||||
`AuthType: NONE`) is a public, unauthenticated target. An attacker can
|
||||
flood it with requests, exhausting Lambda concurrency and making token
|
||||
verification fail for all clients (a cheap DoS).
|
||||
|
||||
**Mitigations:**
|
||||
- **Reserved concurrency (10, max ~100 RPS):** the JWKS Lambda has a
|
||||
reserved-concurrency limit of 10 (set in the CloudFormation
|
||||
template). This caps the blast radius — a flood saturates the JWKS
|
||||
Lambda but does NOT exhaust the account-wide concurrency pool, so
|
||||
`nova-idp-auth` and `nova-idp-token-vend` keep serving.
|
||||
- **Client-side caching (1h):** the JWKS response carries
|
||||
`Cache-Control: max-age=3600`. Clients (`pyjwt.PyJWK` client) cache
|
||||
the keys for 1h, so a JWKS outage does not immediately break
|
||||
verification — already-cached keys keep working.
|
||||
- **Optional CloudFront + WAF (rate-based rule):** `nova idp setup
|
||||
--apply --public-jwks-domain <fqdn>` fronts the function URL with a
|
||||
CloudFront distribution + a WAF web ACL with a rate-based rule
|
||||
(e.g. block an IP after 2000 req/5min). **For any public deployment,
|
||||
set `--public-jwks-domain`.** Without it the function URL is bare —
|
||||
fine for piloting, exposed for production.
|
||||
|
||||
**Residual risk:** medium. The reserved concurrency bounds the cost
|
||||
but a determined attacker can still keep the JWKS Lambda saturated.
|
||||
The WAF + CloudFront path is the production-grade control. JWKS is
|
||||
inherently public (clients MUST fetch it without auth) — this is a
|
||||
fundamental OIDC property, not a Nova design flaw.
|
||||
|
||||
### T-4 — ABAC bypass (C-6.1 / C-7.1)
|
||||
|
||||
**Threat:** the ABAC policy engine (`kyverno-json` / `kj`) fails to
|
||||
load, crashes, or is misconfigured, and the token-vend Lambda vends a
|
||||
token anyway (fails open). This would bypass the authorization gate —
|
||||
every active PAT gets a token regardless of the policy.
|
||||
|
||||
**Mitigations:**
|
||||
- **Fail-closed (C-6.1/C-7.1 — the grill's #1 finding):** the
|
||||
token-vend Lambda's `_evaluate_abac_fail_closed` returns
|
||||
`(False, [], "", "abac_eval_failed")` if:
|
||||
- `KyvernoJsonEngine.is_configured()` returns `False` (`kj` absent),
|
||||
- `get_engine()` raises (engine registry error),
|
||||
- `evaluate_token_vend_policy()` raises (policy parse error, `kj`
|
||||
runtime error).
|
||||
In all three cases the Lambda returns **403** + an audit event
|
||||
`token.vend.denied` (reason `abac_eval_failed`). **Never fails open.**
|
||||
This is INV-17's runtime guarantee — without it, INV-17 is
|
||||
documentation, not a control.
|
||||
- **Verified by `tests/test_abac_fail_closed.py` (7 tests):**
|
||||
engine-not-configured, evaluate-raises, policy-parse-error, ABAC
|
||||
denies, revoked PAT, unknown PAT, audit-event-emitted-on-denial.
|
||||
- **Policy version in every audit event (D-231):** the git blob SHA of
|
||||
`platform/abac/token-vend.policy` is recorded in every
|
||||
`token.vend.allowed`/`token.vend.denied` event. An auditor can
|
||||
reconstruct which policy version governed each vend.
|
||||
|
||||
**Residual risk:** low (given the fail-closed semantics). The
|
||||
remaining risk is a policy-authoring bug (the policy allows too much)
|
||||
— mitigated by PR review (D-231: Platform Security owns the policy)
|
||||
and the policy-version audit trail.
|
||||
|
||||
### T-5 — KMS signing key compromise
|
||||
|
||||
**Threat:** an attacker gains `kms:Sign` permission on
|
||||
`alias/nova-oidc-signing` and forges OIDC tokens.
|
||||
|
||||
**Mitigations:**
|
||||
- **KMS key policy restricts `kms:Sign` to the token-vend Lambda
|
||||
role.** No other principal (including the operator) can sign. The
|
||||
JWKS Lambda role has `kms:GetPublicKey` only (not `Sign`).
|
||||
- **Key rotation (90 days):** the alias is re-pointed to a new
|
||||
`ECC_NIST_P256` key every 90 days (see
|
||||
`docs/operator-guide-idp.md` §6). The old key stays enabled during
|
||||
the overlap window (≥ max PAT TTL = 24h) so already-issued tokens
|
||||
keep verifying, then is disabled + scheduled for deletion.
|
||||
- **JWKS serves both `kid`s during the overlap window:** the JWKS
|
||||
endpoint lists all keys the alias has pointed at that are still
|
||||
enabled. Clients verify against the `kid` in the token header.
|
||||
|
||||
**Residual risk:** low. KMS key policies are the primary control;
|
||||
rotation bounds the exposure window of a stolen key.
|
||||
|
||||
### T-6 — DER → raw ECDSA signature conversion bug (C-5.2 gotcha)
|
||||
|
||||
**Threat:** 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 (for P-256). If the
|
||||
conversion is wrong (wrong byte order, wrong padding, wrong coordinate
|
||||
length), the resulting JWT will not verify with standard libraries
|
||||
(`pyjwt`, `jose`) — or worse, verifies with a *different* signature
|
||||
than intended (a subtle correctness + security bug).
|
||||
|
||||
This is the **#1 implementation risk** identified in RESEARCH §5. The
|
||||
conversion is in `core/kms_signing.py:der_to_raw_ecdsa`:
|
||||
|
||||
```python
|
||||
r, s = decode_dss_signature(der_sig) # cryptography's ASN.1 parser
|
||||
return r.to_bytes(32, "big") + s.to_bytes(32, "big") # raw r‖s
|
||||
```
|
||||
|
||||
**Mitigations:**
|
||||
- **`decode_dss_signature` from `cryptography`** parses the DER (not a
|
||||
hand-rolled ASN.1 parser — that would be the real risk).
|
||||
- **`to_bytes(32, "big")` zero-pads** each coordinate to exactly 32
|
||||
bytes. A coordinate shorter than 32 bytes (high-order zero bytes)
|
||||
is padded; a coordinate longer than 32 bytes raises `ValueError`
|
||||
(the guard at the top of `der_to_raw_ecdsa`).
|
||||
- **Verified by `tests/test_kms_roundtrip.py` (CAP-037):** sign a JWT
|
||||
via `kms_signing.sign_jwt()` (mock KMS with a test ECC keypair) →
|
||||
fetch JWKS via the JWKS Lambda → verify with `pyjwt` + the JWKS key.
|
||||
The round-trip succeeds only if the DER→raw conversion is
|
||||
byte-correct. This is the regression gate for any change to
|
||||
`kms_signing.py`.
|
||||
|
||||
**Residual risk:** low (given the round-trip test). A KMS-side format
|
||||
change (AWS changes the DER encoding) would break the test loudly.
|
||||
|
||||
### T-7 — No AWS-managed identity (INV-15)
|
||||
|
||||
**Threat:** (architectural invariant, not an attack.) Nova-idp depends
|
||||
on Cognito, IAM Identity Center, or another AWS-managed identity
|
||||
service, creating a vendor lock-in and an opaque trust boundary.
|
||||
|
||||
**Mitigation:**
|
||||
- **INV-15 (no AWS-managed identity in path):** Nova-idp uses **KMS +
|
||||
DDB + Lambda only.** No Cognito, no IAM Identity Center, no managed
|
||||
user pools. The identity layer is greenfield and fully owned by
|
||||
Nova. This is a constraint, not a mitigation — it shapes the whole
|
||||
design (Argon2id in Lambda instead of Cognito user pools; KMS-signed
|
||||
JWTs instead of Cognito issued tokens; DDB `nova-pats` instead of
|
||||
IAM access keys).
|
||||
- **Verified by inspection:** `core/lambda/nova_idp_auth.py` +
|
||||
`nova_idp_token_vend.py` import only `boto3` (DDB + KMS), `argon2`,
|
||||
`cryptography`, `pyjwt`, and `core.*`. No `cognitoidp` or
|
||||
`identitystore` client calls anywhere in the identity layer.
|
||||
|
||||
**Residual risk:** none (this is a satisfied constraint, not a
|
||||
residual). The trade-off is operational burden (Nova runs its own
|
||||
password hashing, token signing, revocation) in exchange for
|
||||
portability and no opaque trust boundary.
|
||||
|
||||
### T-8 — Audit trail integrity
|
||||
|
||||
**Threat:** an attacker tampers with the audit stream to hide a
|
||||
malicious vend, a revocation, or a policy change.
|
||||
|
||||
**Mitigations:**
|
||||
- **Every event emitted (INV-12):** `cli.invocation`, `auth.sign_up`,
|
||||
`auth.sign_in`, `auth.session_created`, `pat.issued`, `pat.revoked`,
|
||||
`token.vend.allowed`, `token.vend.denied`, `auth.login`,
|
||||
`auth.status`, `auth.revoke` — each is a JSON line on stderr with a
|
||||
timestamp + the relevant identifiers (`user_id`, `jti`, `sub`,
|
||||
`policy_sha`).
|
||||
- **Policy version (git SHA, D-231) in every token-vend event:** the
|
||||
`policy_sha` field lets an auditor reconstruct which policy version
|
||||
governed each vend — a policy change is visible in the audit stream
|
||||
as a `policy_sha` change.
|
||||
- **Raw credentials scrubbed (INV-16/INV-17 spirit):** the
|
||||
`_emit_audit` functions in `nova_idp_auth.py`,
|
||||
`nova_idp_token_vend.py`, and `pat_lifecycle.py` pop any
|
||||
`password`/`pat`/`token`/`raw_pat` kwarg defense-in-depth. The audit
|
||||
stream carries identifiers, not secrets.
|
||||
- **Revoked PATs retained (REQ-343):** `nova-pats` rows are marked
|
||||
`status=revoked`, never deleted. The audit trail of "who was
|
||||
revoked, when" is queryable.
|
||||
|
||||
**Residual risk:** medium (audit integrity is only as strong as the
|
||||
log destination). The Lambdas emit to stderr (CloudWatch Logs by
|
||||
default); the integrity guarantee depends on the downstream log
|
||||
pipeline (immutability, retention). For high-assurance deployments,
|
||||
forward the audit stream to an append-only store (S3 Object Lock, a
|
||||
write-once log service). This is a deployment concern, documented in
|
||||
the operator guide.
|
||||
|
||||
---
|
||||
|
||||
## 4. C-9.2 — INV-18..21 compression audit
|
||||
|
||||
The source spec (the v1.28 design document that was re-mapped into this
|
||||
repo's REQ-323..353 / INV-12..17 — see `REQUIREMENTS.md` §v1.28 "ID
|
||||
re-mapping") referenced `INV-18..21` as "attestation invariants."
|
||||
Those IDs **do not exist in this repo** (this repo's invariants run
|
||||
INV-1..11 for the blockchain/pilot work and INV-12..17 for v1.28). The
|
||||
grill (C-9.2) requires an audit verifying the spec's attestation
|
||||
invariant semantics were fully captured by the re-mapped
|
||||
INV-15/INV-16/INV-17 + REQ-332, with no semantic gap.
|
||||
|
||||
### The spec's attestation invariant semantics (reconstructed)
|
||||
|
||||
The source spec's INV-18..21 expressed four attestation concerns:
|
||||
|
||||
1. **Immutability** — an attestation, once made, cannot be silently
|
||||
altered.
|
||||
2. **Signature verifiability** — the attestation's signature can be
|
||||
independently verified by a third party holding the public key.
|
||||
3. **Key derivation** — the signing key is derived from a known input
|
||||
(the PAT) via a specified KDF, not ad-hoc.
|
||||
4. **No AWS-managed identity** — the attestation scheme does not
|
||||
depend on Cognito / IAM Identity Center (the greenfield constraint).
|
||||
|
||||
### Mapping to the re-mapped invariants + requirements
|
||||
|
||||
| Spec concern | Re-mapped to | Where enforced |
|
||||
|--------------|--------------|----------------|
|
||||
| Immutability | **INV-6** (existing, pre-v1.28 — the immutable audit ledger) + **INV-17** (ABAC discipline — every vend is audited with `policy_sha`) | the audit stream is append-only; `policy_sha` binds each vend to a policy version |
|
||||
| Signature verifiability | **REQ-332** (JWS-from-PAT KDF) + **REQ-337** (KMS-signed OIDC, JWKS verifiable) | `core/jws_attestation.py:verify_attestation` (HS256, constant-time compare); `core/kms_signing.py` + JWKS endpoint |
|
||||
| Key derivation | **REQ-332** (C-5.2 grill fix) — `HKDF-SHA256(PAT, salt='nova-local-attestation', info='jws-signing-key')` → 32-byte symmetric key | `core/jws_attestation.py:derive_signing_key`; verified by `tests/test_jws_attestation.py` |
|
||||
| No AWS-managed identity | **INV-15** (no Cognito / IAM Identity Center in path) | inspection — the identity layer uses KMS + DDB + Lambda only |
|
||||
|
||||
### Conclusion: the compression is sound — no semantic gap
|
||||
|
||||
The spec's four attestation concerns are covered by:
|
||||
- **INV-6** (immutability — the existing audit ledger, carried forward
|
||||
from pre-v1.28 milestones),
|
||||
- **INV-15** (no AWS-managed identity — the greenfield constraint),
|
||||
- **INV-16** (password storage — the Argon2id + no-raw-password rule,
|
||||
which is the attestation *input* integrity for signup),
|
||||
- **INV-17** (ABAC discipline — every vend is policy-gated + audited
|
||||
with `policy_sha`),
|
||||
- **REQ-332** (JWS-from-PAT KDF — the signature + key-derivation
|
||||
scheme for local-review attestations).
|
||||
|
||||
The re-mapping from `INV-18..21` → `INV-15/16/17 + REQ-332` is a
|
||||
**compression** (4 invariants → 3 invariants + 1 requirement), not a
|
||||
**drop**. The four original concerns (immutability, signature
|
||||
verifiability, key derivation, no-managed-identity) each have a
|
||||
load-bearing home in the re-mapped set. **No attestation invariant
|
||||
semantics were silently dropped.**
|
||||
|
||||
The compression is *justified* because:
|
||||
- INV-6 already covered audit immutability (re-stating it as INV-18
|
||||
would have been a duplicate of an existing invariant).
|
||||
- INV-15 already covered the no-managed-identity constraint
|
||||
(re-stating it as INV-21 would have been a duplicate).
|
||||
- INV-16 + INV-17 cover the input-integrity + policy-discipline
|
||||
concerns that the spec's INV-19/20 expressed as attestation-specific
|
||||
invariants (they are in fact general identity-layer invariants, not
|
||||
attestation-specific).
|
||||
- REQ-332 carries the signature + KDF detail that the spec's INV-18
|
||||
hand-waved ("public key derivable from the PAT") — and corrects it
|
||||
to a sound symmetric scheme (C-5.2).
|
||||
|
||||
### Audit verification (how to re-run this audit)
|
||||
|
||||
```sh
|
||||
# 1. Confirm INV-18..21 do not exist in this repo.
|
||||
grep -rE 'INV-1[89]|INV-2[01]' .ciagent/ docs/ core/ tests/ \
|
||||
| grep -v 'INV-18..21' # only the C-9.2 audit references should remain
|
||||
|
||||
# 2. Confirm the re-mapped invariants + REQ-332 exist + are tested.
|
||||
pytest tests/test_jws_attestation.py tests/test_abac_fail_closed.py \
|
||||
tests/test_kms_roundtrip.py tests/test_argon2_fail_closed.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Test coverage summary
|
||||
|
||||
| Threat | Test file | What it verifies |
|
||||
|--------|-----------|------------------|
|
||||
| T-1 (password) | `tests/test_argon2_fail_closed.py` | 503 on argon2 unavailable (no weak hash) |
|
||||
| T-1 (password) | `tests/test_idp_auth.py` | no raw password in DDB item or logs (INV-16) |
|
||||
| T-2 (PAT theft) | `tests/test_auth_commands.py` | credentials.json has OIDC token, NOT raw PAT (C-7.3) |
|
||||
| T-2 (PAT theft) | `tests/test_pat_revocation.py` | revocation takes effect <1s (D-229 SLO) |
|
||||
| T-3 (JWKS DDoS) | (CloudFormation template inspection) | reserved concurrency = 10; WAF with `--public-jwks-domain` |
|
||||
| T-4 (ABAC bypass) | `tests/test_abac_fail_closed.py` (7 tests) | fail-closed on engine absent / error / deny (C-6.1) |
|
||||
| T-5 (KMS key) | `tests/test_kms_roundtrip.py` | KMS sign → JWKS → pyjwt verify (CAP-037) |
|
||||
| T-6 (DER→raw) | `tests/test_kms_roundtrip.py` | the round-trip succeeds only if DER→raw is byte-correct |
|
||||
| T-7 (no managed id) | (inspection) | no `cognitoidp` / `identitystore` imports in the identity layer |
|
||||
| T-8 (audit) | `tests/test_e2e_idp.py` | the full audit chain is present + linked (REQ-348) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open items (deferred, not blocking v1.28)
|
||||
|
||||
- **WAF rate-limit tuning:** the default rate-based rule threshold
|
||||
(2000 req/5min/IP) is a pilot-scale guess. Production tuning needs
|
||||
real traffic data. Tracked as a post-v1.28 ops task.
|
||||
- **Audit log forwarding to an append-only store** (S3 Object Lock):
|
||||
the Lambdas emit to stderr / CloudWatch Logs by default. High-
|
||||
assurance deployments should forward to a write-once destination.
|
||||
Documented in the operator guide; not enforced in code.
|
||||
- **PAT theft detection:** there is no anomaly detection on PAT usage
|
||||
(e.g. a vend from a new geography). The TTL + revocation is the
|
||||
control. Detection is a future milestone.
|
||||
@@ -0,0 +1 @@
|
||||
"""Nova CLI package — thin subcommand delegates to core.* (P1, REQ-324)."""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""nova apply — resolve a contract + synthesize local env (REQ-330, REQ-332).
|
||||
|
||||
Subcommand (≤50 lines, ≤3 functions, delegates to core/ — NFR-7).
|
||||
nova apply --local --contract .nova/contract.yml [--sign-local-review]
|
||||
nova apply --contract contracts/microservice.yml --out stack.json
|
||||
|
||||
--local: calls core.env.synthesize_local_env() + core.contract_resolver.resolve()
|
||||
--sign-local-review: calls core.jws_attestation.sign_attestation() (REQ-332)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core import env
|
||||
from core.contract_resolver import resolve
|
||||
from core.jws_attestation import sign_attestation
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("apply", help="resolve a contract (+ local env synth)")
|
||||
p.add_argument("--contract", default=".nova/contract.yml", help="contract YAML path")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.add_argument("--local", action="store_true", help="synthesize a local env (no AWS)")
|
||||
p.add_argument("--environment", default=None, help="environment override")
|
||||
p.add_argument("--sign-local-review", action="store_true", help="sign a local-review attestation (REQ-332)")
|
||||
p.add_argument("--pat", default=None, help="PAT for --sign-local-review")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
synth = env.synthesize_local_env(args.contract, environment=args.environment) if args.local else None
|
||||
env_override = (synth["name"] if isinstance(synth, dict) else None) or args.environment
|
||||
result = resolve(args.contract, environment_override=env_override)
|
||||
blob = json.dumps(result, indent=2) + "\n"
|
||||
pat = args.pat or env.get_env("PAT", "") or ""
|
||||
attestation = sign_attestation({"contract": args.contract, "review": "local"}, pat) if (args.sign_local_review and pat) else None
|
||||
blob = blob + (attestation + "\n" if attestation else "")
|
||||
print(blob) if args.out is None else open(args.out, "w").write(blob)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova apply --contract <contract.yml> [--local] [--sign-local-review]", file=sys.stderr)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""nova attestation-matrix — run the 8-concern attestation matrix (REQ-109)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.attestation_matrix import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("attestation-matrix", help="run the 8-concern attestation matrix")
|
||||
p.add_argument("env", help="target environment (dev/qa/prod/dr)")
|
||||
p.add_argument("evidence", nargs="?", default=None, help="evidence JSON path")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
argv = ["nova-attestation-matrix", args.env] + ([args.evidence] if args.evidence else [])
|
||||
return cli_main(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova attestation-matrix <env> [evidence.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""nova auth — login / revoke / status subcommands (REQ-344, C-7.3).
|
||||
|
||||
Subpackage entry point: ``add_parser`` registers the ``auth`` subparser
|
||||
with ``login``/``revoke``/``status`` sub-subcommands, each delegating to
|
||||
its module's ``run``. Discovered by ``nova/cli.py`` via
|
||||
``pkgutil.iter_modules`` (this package's ``add_parser`` is the hook).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("auth", help="Nova IdP auth (login/revoke/status)")
|
||||
sub = p.add_subparsers(dest="auth_command", required=True)
|
||||
from nova.auth import login as _login, revoke as _revoke, status as _status
|
||||
_login.add_parser(sub)
|
||||
_revoke.add_parser(sub)
|
||||
_status.add_parser(sub)
|
||||
return p
|
||||
@@ -0,0 +1,58 @@
|
||||
"""nova auth login — session/PAT → OIDC token, store locally (REQ-344, C-7.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import store_credential, credentials_path
|
||||
|
||||
|
||||
def _vend(pat: str, env: str, endpoint: str) -> dict:
|
||||
"""Call the token-vend Lambda (locally or via the function URL)."""
|
||||
if endpoint and endpoint.startswith("http"):
|
||||
import urllib.request
|
||||
body = json.dumps({"token": pat, "environment": env}).encode()
|
||||
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
# Local: invoke the Lambda in-process.
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_token_vend.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_token_vend", p)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
resp = mod.lambda_handler({"body": json.dumps({"token": pat, "environment": env})}, None)
|
||||
return json.loads(resp["body"])
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("login", help="exchange a PAT/session for an OIDC token")
|
||||
p.add_argument("--pat", default=None, help="PAT JWT (prompted if absent)")
|
||||
p.add_argument("--session", default=None, help="session token (alias for --pat)")
|
||||
p.add_argument("--environment", default="dev", help="target environment")
|
||||
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||
help="token-vend function URL (empty = local)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
pat = args.pat or args.session or os.environ.get("NOVA_PAT")
|
||||
if not pat:
|
||||
pat = sys.stdin.readline().strip()
|
||||
if not pat:
|
||||
print("error: no PAT/session provided", file=sys.stderr); return 1
|
||||
result = _vend(pat, args.environment, args.endpoint)
|
||||
if "token" not in result:
|
||||
print(f"error: {result.get('error', result)}", file=sys.stderr); return 2
|
||||
import base64
|
||||
payload = json.loads(base64.urlsafe_b64decode(result["token"].split(".")[1] + "=="))
|
||||
store_credential(
|
||||
jti=payload.get("jti", ""), cred_type=payload.get("typ", "nova_oidc_token"),
|
||||
exp=payload.get("exp", 0), oidc_token=result["token"],
|
||||
)
|
||||
print(f"logged in: jti={payload.get('jti')} exp={payload.get('exp')} "
|
||||
f"file={credentials_path()}")
|
||||
return 0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""nova auth revoke --pat <jti> — revoke a PAT (REQ-344, D-229)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import emit_revoke_audit
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("revoke", help="revoke a PAT by jti")
|
||||
p.add_argument("--pat", required=True, help="PAT jti to revoke")
|
||||
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||
help="token-vend function URL (empty = local DDB)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def _revoke_remote(jti: str, endpoint: str) -> dict:
|
||||
import json, urllib.request
|
||||
body = json.dumps({"action": "revoke_pat", "jti": jti}).encode()
|
||||
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
try:
|
||||
if args.endpoint and args.endpoint.startswith("http"):
|
||||
_revoke_remote(args.pat, args.endpoint)
|
||||
else:
|
||||
from core.pat_lifecycle import revoke_pat
|
||||
revoke_pat(args.pat)
|
||||
emit_revoke_audit(args.pat)
|
||||
print(f"revoked: jti={args.pat}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"error: {e}", file=sys.stderr); return 2
|
||||
@@ -0,0 +1,34 @@
|
||||
"""nova auth status — print active credential + mode (REQ-344)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import active_credential, emit_status_audit
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("status", help="show active credential + client mode")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
cred = active_credential()
|
||||
emit_status_audit()
|
||||
mode, reason = resolve_mode_from_env(
|
||||
credential_type=cred.get("type") if cred else None,
|
||||
)
|
||||
if cred is None:
|
||||
print(f"no active credential (mode={mode}, reason={reason})")
|
||||
return 0
|
||||
print(json.dumps({
|
||||
"active_credential_jti": cred.get("jti"),
|
||||
"type": cred.get("type"),
|
||||
"exp": cred.get("exp"),
|
||||
"mode": mode,
|
||||
"selection_reason": reason,
|
||||
}, indent=2))
|
||||
return 0
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Nova CLI entry point — dispatch + audit (P1, REQ-324, INV-12).
|
||||
|
||||
Auto-discovers nova/<module>.py subcommands; each exports
|
||||
add_parser(subparsers) + run(args) -> int. Resolves the client mode
|
||||
via core.mode_resolver and emits a cli.invocation audit event (stderr
|
||||
JSON line stub) before dispatching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import pkgutil
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
|
||||
|
||||
def _emit_invocation(mode, reason, cred_type, command, args):
|
||||
"""INV-12: emit cli.invocation audit event to stderr (stub)."""
|
||||
event = {
|
||||
"event": "cli.invocation",
|
||||
"mode": mode,
|
||||
"selection_reason": reason,
|
||||
"credential_type": cred_type,
|
||||
"command": command,
|
||||
"args": args,
|
||||
}
|
||||
sys.stderr.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
import nova
|
||||
|
||||
|
||||
def _build_parser():
|
||||
parser = argparse.ArgumentParser(prog="nova", description="Nova platform CLI")
|
||||
parser.add_argument("--mode", choices=["agent", "interactive"], default=None)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for mod_info in pkgutil.iter_modules(nova.__path__):
|
||||
name = mod_info.name
|
||||
if name == "cli":
|
||||
continue
|
||||
mod = importlib.import_module(f"nova.{name}")
|
||||
mod.add_parser(sub)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list] = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
mode, reason = resolve_mode_from_env()
|
||||
arg_dict = {k: v for k, v in vars(args).items() if k != "_run"}
|
||||
_emit_invocation(mode, reason, None, args.command, arg_dict)
|
||||
return args._run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""nova confidence — compute the confidence signal (REQ-19)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.confidence_signal import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("confidence", help="compute the confidence signal")
|
||||
p.add_argument("inputs_json", help="path to an inputs JSON file")
|
||||
p.add_argument("environment", help="target environment")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return cli_main(["nova-confidence", args.inputs_json, args.environment])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova confidence <inputs.json> <environment>", file=sys.stderr)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""nova decommission — transform a resolved stack for decommission (REQ-92)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.decommission_transform import decommission_transform
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("decommission", help="transform a stack JSON for decommission")
|
||||
p.add_argument("stack_json", help="path to a resolved stack JSON")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.stack_json) as fh:
|
||||
stack = json.load(fh)
|
||||
out = decommission_transform(stack)
|
||||
blob = json.dumps(out, indent=2)
|
||||
print(blob)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova decommission <stack.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""nova env-check — check that an environment is bound (REQ-181)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core.environment_check import check
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("env-check", help="check that an environment is bound")
|
||||
p.add_argument("contract", nargs="?", default=None, help="contract path")
|
||||
p.add_argument("--env", default=None, help="environment name override")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
ok, message = check(contract_path=args.contract, env_name=args.env)
|
||||
print(message) if ok else sys.stderr.write(message + "\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova env-check <contract.yml> [--env name]", file=sys.stderr)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""nova env-transition — detect/record the applied environment (REQ-183)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.env_transition import detect_prior_env, record_applied_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("env-transition", help="detect/record the env for a contract")
|
||||
sub = p.add_subparsers(dest="env_transition_command", required=True)
|
||||
pd = sub.add_parser("detect")
|
||||
pd.add_argument("--contract-id", required=True)
|
||||
pd.add_argument("--consumer-repo", required=True)
|
||||
pd.add_argument("--new-env", required=True)
|
||||
pr = sub.add_parser("record")
|
||||
pr.add_argument("--contract-id", required=True)
|
||||
pr.add_argument("--consumer-repo", required=True)
|
||||
pr.add_argument("--env", required=True)
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
cmd = args.env_transition_command
|
||||
payload = _dispatch(cmd, args)
|
||||
print(json.dumps(payload))
|
||||
return 0 if cmd == "detect" else (0 if payload["recorded"] else 1)
|
||||
|
||||
|
||||
def _dispatch(cmd, args) -> dict:
|
||||
return {"prior_env": detect_prior_env(args.contract_id, args.consumer_repo, args.new_env)} if cmd == "detect" else {"recorded": record_applied_env(args.contract_id, args.consumer_repo, args.env)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova env-transition detect|record ...", file=sys.stderr)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""nova hitl — attest a promotion gate (REQ-108)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from core.hitl_gates import attest, approver_from_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("hitl", help="attest a promotion gate")
|
||||
p.add_argument("--contract-id", required=True)
|
||||
p.add_argument("--env", required=True, help="dev/qa/prod/dr")
|
||||
p.add_argument("--evidence", default=None, help="evidence JSON path")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
evidence = _load_evidence(args.evidence)
|
||||
approver = approver_from_env() or ""
|
||||
ok, reason = attest(args.contract_id, args.env, approver, evidence)
|
||||
print(f"HITL PASS: {reason}") if ok else sys.stderr.write(f"HITL BLOCK: {reason}\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _load_evidence(path):
|
||||
return {} if path is None else json.loads(open(path).read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova hitl --contract-id <id> --env <env> [--evidence f.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""nova idp — IdP setup subcommands (REQ-340, C-2.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("idp", help="Nova IdP management (setup)")
|
||||
sub = p.add_subparsers(dest="idp_command", required=True)
|
||||
from nova.idp import setup as _setup
|
||||
_setup.add_parser(sub)
|
||||
return p
|
||||
@@ -0,0 +1,45 @@
|
||||
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, REQ-369, ≤50 lines)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_setup():
|
||||
"""Load core/lambda/nova_idp_setup.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_setup.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_setup", p)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack")
|
||||
p.add_argument("--check", action="store_true", help="check prerequisites")
|
||||
p.add_argument("--apply", action="store_true", help="terraform apply (REQ-369; CFN fallback)")
|
||||
p.add_argument("--verify", action="store_true", help="terraform plan (REQ-369; KMS fallback)")
|
||||
p.add_argument("--dry-run", action="store_true", help="resource summary only")
|
||||
p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
mod = _load_setup()
|
||||
if args.check:
|
||||
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
|
||||
if args.verify:
|
||||
if shutil.which("terraform"):
|
||||
r = mod.terraform_plan(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
if args.apply or args.dry_run:
|
||||
if not args.dry_run and shutil.which("terraform"):
|
||||
r = mod.terraform_apply(); print(json.dumps(r, indent=2)); return 0 if r["deployed"] else 1
|
||||
r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run)
|
||||
print(json.dumps(r["summary"], indent=2))
|
||||
return 0 if (r["deployed"] or args.dry_run) else 1
|
||||
print("usage: nova idp setup --check|--apply|--verify [--dry-run]", file=sys.stderr)
|
||||
return 2
|
||||
@@ -0,0 +1,20 @@
|
||||
"""nova init — scaffold .nova/ + secrets .gitignore (P1, REQ-325)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.init_scaffold import scaffold
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("init", help="scaffold .nova/ + .gitignore in cwd")
|
||||
p.add_argument("--force", action="store_true", help="overwrite existing .nova/")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return scaffold(force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova init [--force]", file=sys.stderr)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""nova onboard — generate an env binding from an onboarding request (REQ-181)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.onboarding import generate_env_file
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("onboard", help="generate an env binding from a request")
|
||||
p.add_argument("--request", default=None, help="inline request JSON")
|
||||
p.add_argument("request_file", nargs="?", default=None, help="request JSON path")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.add_argument("--template-env", default="dev")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
request = _load_request(args)
|
||||
env = generate_env_file(request, template_env=args.template_env)
|
||||
blob = json.dumps(env, indent=2) + "\n"
|
||||
print(blob) if args.out is None else open(args.out, "w").write(blob)
|
||||
return 0
|
||||
|
||||
|
||||
def _load_request(args):
|
||||
return json.loads(args.request) if args.request else json.loads(open(args.request_file).read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova onboard <request.json> [--out env.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""nova outbox — write an evidence event to the DynamoDB outbox (D-P10-3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.outbox_writer import write_event
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("outbox", help="write an evidence event to the outbox")
|
||||
p.add_argument("event_json", help="path to an event JSON file")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.event_json) as fh:
|
||||
event = json.load(fh)
|
||||
item = write_event(event)
|
||||
print(json.dumps({k: list(v.values())[0] for k, v in item.items()}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova outbox <event.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""nova policy — print the active policy engine status (REQ-122)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.policy_engine import get_engine, get_policy_root
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("policy", help="print the active policy engine status")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
eng = get_engine()
|
||||
print(json.dumps({
|
||||
"engine": eng.name,
|
||||
"is_configured": eng.is_configured(),
|
||||
"policy_root": str(get_policy_root()),
|
||||
}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova policy", file=sys.stderr)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""nova publish-outputs — publish stack outputs to SSM + format a PR comment (REQ-168)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.output_publisher import publish_to_ssm, format_comment
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("publish-outputs", help="publish outputs to SSM + format comment")
|
||||
p.add_argument("outputs_json", help="path to an outputs JSON file")
|
||||
p.add_argument("environment")
|
||||
p.add_argument("contract_id")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.outputs_json) as fh:
|
||||
outputs = json.load(fh)
|
||||
ssm_results = publish_to_ssm(outputs, args.environment, args.contract_id)
|
||||
print(format_comment(outputs, args.environment, args.contract_id, ssm_results))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova publish-outputs <outputs.json> <env> <contract-id>", file=sys.stderr)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""nova readiness — submission readiness check (REQ-178)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.submission_readiness import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("readiness", help="submission readiness check")
|
||||
p.add_argument("contract_json", help="path to a contract/submission JSON")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return cli_main(["nova-readiness", args.contract_json])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova readiness <contract.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""nova regression — run the regression gate and write the report (REQ-177)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core import env as _envhelper
|
||||
from core.regression_verify import run_regression, write_report
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("regression", help="run the regression gate + write report")
|
||||
p.add_argument("--milestone", default=None)
|
||||
p.add_argument("--phase", type=int, default=None)
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
milestone = args.milestone or _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
|
||||
phase = args.phase if args.phase is not None else int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
|
||||
report = run_regression(milestone=milestone, phase=phase)
|
||||
md, js = write_report(report)
|
||||
print(f"regression: {report.summary} -> {md}")
|
||||
return 0 if report.passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova regression [--milestone v1.x] [--phase N]", file=sys.stderr)
|
||||