diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md index 32fb80a..2910c0c 100644 --- a/.ciagent/PLAN.md +++ b/.ciagent/PLAN.md @@ -1,1316 +1,1128 @@ --- -phase: 7 -name: architecture-v1-finalization +phase: 8 +name: aws-bootstrap milestone: v1.1 milestone_type: feature status: planned -requirements: [REQ-16, REQ-17, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22] +requirements: [REQ-23] must_haves: - # REQ-16 — architecture snapshot + status bump - - "docs/architecture-v1.0.md exists as a snapshot of docs/architecture.md with all 11 open decisions applied; status note bumped from v0.2 to v1.0; a 'v1.1 spike = Phases 08-10' + 'v1.2 build-out' note is present" - - "All 11 open-decision IDs are recorded in PROJECT.md (W1.A, W1.B, W2.A, W3.D, W3.E, BA.A, BA.B, BA.C, BA.D, BA.E, BA.F) + the OpenTofu timing sub-decision (Q1.3)" - # REQ-17 — Target Stack IR schema - - "schemas/ir.schema.json exists, validates as JSON Schema draft 2020-12, and encodes: resources (typed inputs/outputs/NFRs), relationships (single parent per child), composition (tree, max-depth-5), policy hooks; substrate-agnostic (no Terraform-specific terms in the schema body)" - # REQ-18 — PolicyCheckResult schema + Checkov adapter - - "schemas/policy_check_result.schema.json exists, validates as JSON Schema draft 2020-12, and matches ARCHITECTURE.md §12.6 canonical shape" - - "adapters/terraform/policy/checkov_adapter.py exists, py_compiles, and translates Checkov JSON → PolicyCheckResult records; maps CKV_AWS_* to the 4 L2 checks + tag/naming; emits a single ACDL_TAG_NAMING SKIPPED PolicyCheckResult (severity: info) per D-043" - # REQ-19 — confidence signal - - "platform/confidence_signal.py exists, py_compiles, and implements the 6-input weighted sum (policy 0.30, validation 0.25, freshness 0.10, source 0.15, history 0.10, nfrs 0.10 per D-040); per-env thresholds (dev 0.50, qa 0.75, prod 0.90, dr 0.95); severity→penalty (critical hard-override, high -0.2, medium -0.05, low -0.01, info 0.0); output {score, band, perInput, reasonCodes}; halts with reason on missing input" - # REQ-20 — audit ledger design - - "platform/audit_ledger_design.md exists and specifies: S3 Object Lock compliance mode 7-yr + DynamoDB outbox RPO=0 + JWS detached signature (platform KMS key, quarterly rotation) + prev_event_hash SHA-256 chain over canonical JSON + daily checkpoints; spike scope (D-041) = v1.0 hash chain + outbox write; v1.2 build-out = Object Lock + JWS + async worker + DLQ clearly marked" - # REQ-21 — HITL matrix + SoD - - "platform/hitl_matrix_design.md exists and contains the full 8-concern attestation matrix from docs/architecture.md §10.4; pre-execution gate model; timeout 1d warn / 2d freeze; rejection → HELD + supersedes; reviewer routing via CODEOWNERS + Gitea workflow_dispatch gitea.actor (D-042, no Environments API)" - - "platform/separation_of_duties.py exists, py_compiles, and reads approver_qa from the DynamoDB outbox, compares to the prod-dispatch gitea.actor, blocks + emits SEPARATION_OF_DUTIES_VIOLATION on equality, routes halt artifact to SRE on-call" - # REQ-22 — contract schema - - "schemas/contract.schema.json exists, validates as JSON Schema draft 2020-12, and encodes per-env mandatory inputs (dev: stack+environment; qa adds validation.e2eSuite+loadTest; prod adds runbook+dashboard+oncall; dr adds drDrillRef); inputs always optional; profile: agentic marker with naturalLanguageIntent/confidenceAtSubmission/agentTrace optional everywhere" - # verification - - "scripts/verify_phase07.sh exists and asserts: all 6 schema/design files exist; the 3 JSON Schemas validate (python3 -m jsonschema); the 2 .py files py_compile; the 2 .md design files are non-empty; all 11 decision IDs + OpenTofu present in PROJECT.md; docs/architecture-v1.0.md exists + status is v1.0" - - "Traceability updated: REQUIREMENTS.md REQ-16..22 → covered/complete; ROADMAP.md Phase 07 → complete" + # REQ-23a — S3 state bucket + DynamoDB outbox table + - "terraform/bootstrap/create_state_backend.py exists, py_compiles, and creates (idempotently) the S3 state bucket acdl-tfstate-581513795199-us-east-1 (versioning enabled) + the DynamoDB table acdl-outbox (PAY_PER_REQUEST, PK contractId string, SK eventType#eventTs string); writes terraform/bootstrap/.bootstrap_state.json marker {account_id, bucket_name, table_name, region, created_at}" + # REQ-23b — IAM user + scoped policy + initial access key + - "terraform/bootstrap/spike_runner_policy.json exists, is a valid JSON IAM policy document (Version 2012-10-17), and contains explicit ALLOW for s3:PutObject/GetObject/DeleteObject/ListBucket on the state bucket ARNs, dynamodb:GetItem/PutItem/DeleteItem/UpdateItem on the outbox table ARN, sts:GetCallerIdentity on *; a final Deny statement (Action *, NotResource = the above ARNs) enforcing least privilege; NO terraform apply permission (plan-only spike per Out of Scope)" + - "terraform/bootstrap/create_iam_user.py exists, py_compiles, and creates (idempotently) the IAM user acdl-spike-runner, attaches the inline policy from spike_runner_policy.json (account id + region substituted), creates an initial access key, and prints ACM_AWS_ACCESS_KEY_ID=<...> + ACDL_AWS_SECRET_ACCESS_KEY=<...> to stdout for the orchestrator to capture (NEVER committed)" + # D-039 — key rotation script + - "scripts/rotate_spike_key.sh exists, bash -n passes, and uses boto3 with the bootstrap root key (from env vars ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID + ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY, NEVER committed) to: list acdl-spike-runner access keys; if >=1 active key exists, create a new key, deactivate + delete the old; write the new key to gitignored .env.secrets (chmod 600); if 0 keys, create one; idempotent (re-run ends with exactly 1 active key). Optionally uploads to Gitea secrets via PUT /api/v1/repos/continuous-intelligence/acdl/actions/secrets/ if ACDL_GITEA_TOKEN is set; else warns that Gitea secret upload is skipped (v1.2 hardening). The script does NOT rotate the bootstrap root key (D-034 closure is a manual user step)." + # verify + - "scripts/verify_phase08.sh exists, bash -n passes, loads the rotated key from .env.secrets, runs python3 boto3 sts.get_caller_identity() with those creds, asserts the Arn is arn:aws:iam::581513795199:user/acdl-spike-runner (NOT root), asserts the S3 bucket exists via head_bucket, the DynamoDB table via describe_table, the IAM user via get_user with the scoped inline policy present + the Deny statement in the policy; asserts .env.secrets + terraform/bootstrap/.bootstrap_state.json are gitignored; prints 'VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now)'" + # doc + - "terraform/bootstrap/README.md exists and documents the runbook: (1) set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID + ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY env vars to the root key; (2) python3 terraform/bootstrap/create_state_backend.py; (3) python3 terraform/bootstrap/create_iam_user.py (capture stdout); (4) bash scripts/rotate_spike_key.sh; (5) bash scripts/verify_phase08.sh; (6) MANUAL: rotate/deactivate the root key in the AWS IAM console (D-034 closure — the user does this, not the script). Notes the spike-scope (D-039): the spike uses the rotated acdl-spike-runner key for Phases 09-10; real OIDC is v1.2." + - ".gitignore has entries for .env.secrets + terraform/bootstrap/.bootstrap_state.json" + # traceability + - "Traceability updated: REQUIREMENTS.md REQ-23 → covered/complete; ROADMAP.md Phase 08 → complete; PROJECT.md D-034 marked closed (bootstrap root key rotated by the user after Phase 08)" verification: - typecheck: "python3 -m py_compile platform/confidence_signal.py platform/separation_of_duties.py adapters/terraform/policy/checkov_adapter.py && python3 -m jsonschema schemas/ir.schema.json schemas/policy_check_result.schema.json schemas/contract.schema.json" - test: "scripts/verify_phase07.sh" - build: "no-op # Phase 07 is design-authoring; terraform init is Phase 09+" + typecheck: "python3 -m py_compile terraform/bootstrap/create_state_backend.py terraform/bootstrap/create_iam_user.py 2>/dev/null; bash -n scripts/rotate_spike_key.sh scripts/verify_phase08.sh" + test: "scripts/verify_phase08.sh" + build: "no-op # terraform init is Phase 09; Phase 08 is infra bootstrap via boto3, not Terraform" --- -# Phase 07 — architecture-v1-finalization PLAN +# Phase 08 — aws-bootstrap PLAN ## Goal -Finalize the ACDL architecture to v1.0 by authoring the 6 locked -schema/design files + 1 architecture snapshot that resolve all 11 open -decisions recorded in `PROJECT.md` (D-038). This phase is -**design-authoring, not runtime code**: the files are the contracts the -v1.1 spike (Phases 08-10) implements against, not the spike itself. +Bootstrap the AWS substrate for the v1.1 spike: create the S3 state +bucket + DynamoDB outbox table, a minimal IAM user (`acdl-spike-runner`) +with a least-privilege scoped policy + a per-run-rotated access key +(waiver D-039), and a rotation script — all using the **root account +credential for account 581513795199 exactly once**, then close D-034 by +having the user manually rotate the root key afterward. -The deliverables, mapped to requirements: +This phase is **infra bootstrap, not runtime code**. The authors write +boto3 + bash scripts that provision AWS resources; Wave 5 is the only +wave that touches real AWS. Real OIDC federation is **deferred to v1.2** +(blocked on go-gitea/gitea#36988, RESEARCH TARGET 1, conf 0.95); the spike +uses a per-run-rotated long-lived IAM user key (D-039) — the cleanest +available option that still touches real AWS without a *persistently* +long-lived key. -| REQ | File(s) | Owner persona (PERSONAS.md territory) | -|-----|---------|---------------------------------------| -| REQ-16 | `docs/architecture-v1.0.md` (snapshot of `docs/architecture.md` with 11 resolutions applied + status bumped to v1.0) | lead-developer (owns `docs/**` meta) | -| REQ-17 | `schemas/ir.schema.json` (Target Stack IR) | platform-engineer (owns the IR; `schemas/` co-owned with backend-engineer) | -| REQ-18 | `schemas/policy_check_result.schema.json` + `adapters/terraform/policy/checkov_adapter.py` | security-engineer (owns `adapters/terraform/policy/**`); schema co-authored | -| REQ-19 | `platform/confidence_signal.py` (6-input weighted signal) | backend-engineer (6-input weighted sum + thresholds) + security-engineer (severity→penalty + critical-override) — co-owned per PERSONAS.md conflict resolutions | -| REQ-20 | `platform/audit_ledger_design.md` (tiered ledger) | security-engineer | -| REQ-21 | `platform/hitl_matrix_design.md` + `platform/separation_of_duties.py` | security-engineer | -| REQ-22 | `schemas/contract.schema.json` (contract schema) | backend-engineer (owns `schemas/**` contract + IR + PolicyCheckResult) | -| — | `scripts/verify_phase07.sh` + traceability update | lead-developer | +**Re-scope note (RESEARCH TARGET 1 + D-039):** the original Phase 08 +goal in `PROJECT.md` ("create an IAM role + OIDC trust policy for the +act_runner") is **moot** for v1.1 because Gitea Actions does not support +`id-token: write`. The operational-parameter row "OIDC IAM role +`acdl-act-runner-role`" in PROJECT.md is therefore **not realized in +v1.1** — the spike creates an IAM *user* (`acdl-spike-runner`) with a +scoped policy + a rotated key, not an OIDC role. The OIDC role lands in +v1.2 when PR #36988 merges. This plan records that re-scope as decision +D-P08-2 below. -The spike scope (D-041, D-043) means the designs author the **full -target** but clearly mark what is spike-scope vs v1.2 build-out. The spike -(Phases 08-10) only runs `terraform plan` + Checkov + confidence + outbox -write; Object Lock / JWS / HITL-wiring / L3B-skill-catalog are design-only -in Phase 07. +The deliverables, mapped to requirements + decisions: + +| REQ / decision | File | Owner persona (PERSONAS.md territory) | +|----------------|------|---------------------------------------| +| REQ-23a | `terraform/bootstrap/create_state_backend.py` (S3 bucket + DynamoDB outbox table) | platform-engineer (owns `terraform/**`, AWS bootstrap) | +| REQ-23b (policy) | `terraform/bootstrap/spike_runner_policy.json` (least-privilege IAM policy) | security-engineer (owns IAM policy review) | +| REQ-23b (user) | `terraform/bootstrap/create_iam_user.py` (IAM user + inline policy + initial key) | platform-engineer (creation) + security-engineer (policy review) | +| D-039 | `scripts/rotate_spike_key.sh` (per-run key rotation) | platform-engineer | +| (verify) | `scripts/verify_phase08.sh` (assert bucket + table + user + policy + rotated-key caller identity) | lead-developer (owns `scripts/verify_phase*.sh`) | +| (doc) | `terraform/bootstrap/README.md` (runbook + D-034 manual step) | lead-developer (owns `docs/**` meta) | +| (gitignore) | `.gitignore` (add `.env.secrets` + `terraform/bootstrap/.bootstrap_state.json`) | lead-developer (owns `.gitignore`) | ## Requirements covered -- **REQ-16** → T-7.1 (`docs/architecture-v1.0.md` snapshot + status bump). -- **REQ-17** → T-7.2 (`schemas/ir.schema.json`). -- **REQ-22** → T-7.3 (`schemas/contract.schema.json`). -- **REQ-18** → T-7.4 (`schemas/policy_check_result.schema.json`) + - T-7.5 (`adapters/terraform/policy/checkov_adapter.py`). -- **REQ-20** → T-7.6 (`platform/audit_ledger_design.md`). -- **REQ-21** → T-7.7 (`platform/hitl_matrix_design.md`) + - T-7.8 (`platform/separation_of_duties.py`). -- **REQ-19** → T-7.9 (`platform/confidence_signal.py`, co-authored). -- **All** → T-7.10 (`scripts/verify_phase07.sh` + traceability update). +- **REQ-23** (re-interpreted per RESEARCH TARGET 1 + D-039): "AWS auth + bootstrap + state backend for the spike: an S3 state bucket + DynamoDB + lock/outbox table + an IAM user with a minimal scoped policy (S3 + + DynamoDB + plan-only). The temporary long-lived key is used once + (waiver D-034) then rotated via `scripts/rotate_spike_key.sh` after each + spike run (D-039). Real OIDC federation is deferred to v1.2." + - **REQ-23a** → T-8.3 (`create_state_backend.py` — S3 + DynamoDB). + - **REQ-23b** → T-8.1 (`spike_runner_policy.json`) + T-8.4 + (`create_iam_user.py`). + - **D-039** → T-8.5 (`rotate_spike_key.sh`). + - **verify** → T-8.6 (`verify_phase08.sh`). + - **doc + gitignore** → T-8.7 (`README.md` + `.gitignore`). + - **D-034 closure** → Wave 5 (EXECUTE-only — the user manually rotates + the root key after the bootstrap; recorded in PROJECT.md). -All 7 requirements (REQ-16..REQ-22) are Phase 07 per REQUIREMENTS.md -traceability table. Phase 07 depends on Phase 06 (complete, v1.1.1): -the new repo layout (`schemas/`, `platform/`, `adapters/`) is scaffolded -empty with `.gitkeep` and ready to receive these files. +REQ-23 is the only requirement assigned to Phase 08 per REQUIREMENTS.md +traceability. Phase 08 depends on Phase 07 (complete, v1.1.2): the +architecture is finalized to v1.0, the audit-ledger design +(`platform/audit_ledger_design.md`) locks the outbox shape (PK +`contractId`, SK `eventType#eventTs`, `PAY_PER_REQUEST`, TTL), and D-039 ++ D-044 are recorded in PROJECT.md. ## Waves -Domain priority order from `PERSONAS.md`: -`coordination → security → platform → backend → frontend`. +Domain priority from PERSONAS.md: `coordination → security → platform → +backend → frontend`. Phase 08 is infra bootstrap, so the active personas +are **security-engineer** (the IAM policy is the security contract), +**platform-engineer** (the AWS resource creation + rotation script), and +**lead-developer** (verify script + README + .gitignore + traceability). +backend/frontend idle (per PERSONAS.md "Phase-specific overrides"). -Phase 07 active personas (PERSONAS.md "Phase-specific overrides"): -**lead-developer** (coordination — the snapshot + verify script), -**backend-engineer** (schemas: contract + co-IR + co-PolicyCheckResult + -co-confidence), **security-engineer** (HITL/ledger/SoD/Checkov-adapter/ -co-confidence), **platform-engineer** (IR). frontend-engineer idle. +Dependency order (infra bootstrap): +- The IAM **policy** is the security contract — it must be reviewed + before the user is created with it (Wave 1). +- The **user creation** script reads the policy file (Wave 2 → Wave 1). +- The **rotation script** runs against the user Wave 2 creates — but it + is authored (not executed) in Wave 3; it depends on the user existing + only at Wave 5 execution time (Wave 3 → Wave 2 for the *contract* — the + script references the user name + policy ARN that Wave 2 establishes). +- The **verify script** asserts everything prior exists (Wave 4 → all). +- **Wave 5 is EXECUTE-only** — runs the authored files against real AWS. + The prior waves are authoring; Wave 5 is the only wave that makes AWS + API calls. This separation is deliberate: it lets the plan be + type-checked (`py_compile` + `bash -n`) without touching AWS, and it + isolates the single use of the root key to one wave. -The files are mostly independent (no runtime dependencies between -schemas), but there are logical dependencies: -- REQ-16 (architecture snapshot) is the umbrella — it references all - the others. -- The 3 JSON Schemas (REQ-17, REQ-18-partial, REQ-22) are independent. -- REQ-19 (confidence_signal.py) consumes PolicyCheckResult records - (REQ-18) — depends on T-7.4. -- REQ-21 (separation_of_duties.py) reads the outbox schema (REQ-20) — - depends on T-7.6. -- REQ-20 + REQ-21 are both security-engineer; one wave, sequential - within. - -Five waves, strictly ordered (each wave depends on the prior wave's -commits being present so the verify script and snapshot can reference -the authored files): +Five waves, strictly ordered: ``` -Wave 1 (lead-developer) — T-7.1 architecture-v1.0.md snapshot (umbrella) -Wave 2 (platform + backend) — T-7.2 ir.schema.json - T-7.3 contract.schema.json (parallel) -Wave 3 (security-engineer) — T-7.4 policy_check_result.schema.json - T-7.5 checkov_adapter.py - T-7.6 audit_ledger_design.md - T-7.7 hitl_matrix_design.md - T-7.8 separation_of_duties.py (sequential) -Wave 4 (backend + security) — T-7.9 confidence_signal.py (co-authored; depends on T-7.4) -Wave 5 (lead-developer) — T-7.10 verify_phase07.sh + traceability +Wave 1 (security-engineer) — T-8.1 spike_runner_policy.json (the security contract) +Wave 2 (platform-engineer) — T-8.2 create_state_backend.py stub/contract + T-8.3 create_state_backend.py (S3 + DynamoDB) + T-8.4 create_iam_user.py (reads Wave 1 policy) +Wave 3 (platform-engineer) — T-8.5 rotate_spike_key.sh (refs Wave 2 user) +Wave 4 (lead-developer) — T-8.6 verify_phase08.sh (asserts all prior) + T-8.7 README.md + .gitignore (runbook + D-034 step) +Wave 5 (lead-developer, EXECUTE) — run create_state_backend.py → create_iam_user.py + → rotate_spike_key.sh → verify_phase08.sh + → close D-034 (user rotates root key manually) ``` **Dependency graph (explicit):** -- Wave 4 → Wave 3 (confidence_signal consumes PolicyCheckResult, T-7.4). -- Wave 4 → Wave 3 (separation_of_duties reads the outbox schema documented - in audit_ledger_design.md, T-7.6 — the .py reads the outbox shape - defined in the .md). -- Wave 5 → all prior waves (verify script asserts all 6 files exist + - validate). -- Wave 1 (snapshot) is umbrella — it references the *decisions* (already - in PROJECT.md), not the *authored files*, so it can land first. It - notes that the 6 files are authored in Phases 07 Waves 2-4. +- Wave 2 → Wave 1 (`create_iam_user.py` reads `spike_runner_policy.json` + and substitutes the account id + region into the ARNs). +- Wave 3 → Wave 2 (`rotate_spike_key.sh` references the `acdl-spike-runner` + user name + the scoped policy that Wave 2 creates; the script is + authored against that contract). +- Wave 4 → all prior (`verify_phase08.sh` asserts the bucket, table, user, + policy, and rotated key all exist + the caller is the user, not root). +- Wave 5 → Wave 4 (EXECUTE-only; runs the authored files in order; the + verify script in Wave 4 is the last file Wave 5 invokes). -**Parallelizable:** Wave 2's two tasks (T-7.2 IR, T-7.3 contract) are -independent JSON Schemas owned by different personas — run in parallel. -All other waves are sequential within the wave. +**Parallelizable:** Wave 2's two authoring tasks (T-8.3 state backend, +T-8.4 IAM user) are independent files owned by the same persona — can be +authored in parallel after T-8.1 lands. All other waves are sequential +within the wave. --- -### Wave 1 — lead-developer: REQ-16 architecture-v1.0.md snapshot +### Wave 1 — security-engineer: REQ-23b policy contract -The umbrella doc. References the 11 resolutions already recorded in -PROJECT.md (does not duplicate them — points at PROJECT.md's -"Open-decision resolutions" table + decisions D-034..D-046). Bumps the -status note from v0.2 → v1.0. Notes the v1.1 spike (Phases 08-10) + -v1.2 build-out boundary. +The IAM policy is the security contract — it is reviewed before the +user is created with it. This is the only Wave 1 task because the policy +document is the input to `create_iam_user.py` (Wave 2). -#### T-7.1 — Author `docs/architecture-v1.0.md` +#### T-8.1 — Author `terraform/bootstrap/spike_runner_policy.json` -- **Owner:** lead-developer (territory: `docs/**` meta only — not - architecture authoring; this is a *snapshot* of the upstream - `docs/architecture.md`, not a re-authoring). -- **Files owned:** `docs/architecture-v1.0.md` (new). +- **Owner:** security-engineer (territory: IAM policy review per + PERSONAS.md "Phase-specific overrides" Phase 08 row — "trust policy + review → now scoped IAM policy review"). +- **Files owned:** `terraform/bootstrap/spike_runner_policy.json` (new); + create `terraform/bootstrap/__init__.py` (empty) + a + `terraform/bootstrap/.gitkeep` if the dir is otherwise empty after + Wave 2 (Wave 2 will populate the dir, so this is just a guard). - **What the file must contain** (cite sources): - 1. **Header** — copy the upstream `docs/architecture.md` header verbatim, - then change the status line from - `Status: Draft v0.2. Section 13 lists 11 open decisions. ...` - to - `Status: v1.0 (snapshot taken in ACDL Phase 07, milestone v1.1). All 11 open decisions in §13 are resolved — see PROJECT.md "Open-decision resolutions" table + decisions D-034..D-046.` - 2. **Resolution session log table** — replace the upstream §0 - resolution-session log with a v1.0 resolution log: for each of the - 11 open IDs (W1.A, W1.B, W2.A, W3.D, W3.E, BA.A, BA.B, BA.C, BA.D, - BA.E, BA.F, Q1.3-OpenTofu), a row with `ID | Resolution (one-line) | - See PROJECT.md`. Do **not** duplicate the full resolution text — - point at PROJECT.md. - 3. **§13 Consolidated Open Design Decisions** — replace the upstream - "🟡 OPEN" markers with "✅ RESOLVED (see PROJECT.md)" for each of the - 11 items, preserving the upstream section structure. - 4. **§14 Document Status** — rewrite to: - `Status: v1.0. All 11 open items in §13 are resolved. The architecture is internally consistent; the v1.1 implementation spike (ACDL Phases 08-10) validates the locked substrate abstraction + contract→IR→adapter path. The v1.2 build-out (Object Lock, JWS, HITL wiring, L3B skill catalog, Kyverno/OPA, OIDC federation) is design-authored in Phase 07 and implemented post-spike.` - 5. **New §15 — Phase 07 authored artifacts** — a table mapping each - of the 6 schema/design files to its REQ + owner persona: - `REQ-17 → schemas/ir.schema.json (platform-engineer)`, - `REQ-18 → schemas/policy_check_result.schema.json + adapters/terraform/policy/checkov_adapter.py (security-engineer)`, - `REQ-19 → platform/confidence_signal.py (backend + security co-authored)`, - `REQ-20 → platform/audit_ledger_design.md (security-engineer)`, - `REQ-21 → platform/hitl_matrix_design.md + platform/separation_of_duties.py (security-engineer)`, - `REQ-22 → schemas/contract.schema.json (backend-engineer)`. - Note that these files land in Waves 2-4 of Phase 07. - 6. **gitea-runner rename (D-046)** — update any `act_runner` reference - in the snapshot to `gitea-runner` (formerly `act_runner`, renamed - 2026-04 in gitea/runner#850) with a one-line note. - 7. **Body** — the rest of the upstream `docs/architecture.md` body - (§§1-12) is copied verbatim; the §12.6 PolicyCheckResult shape, §10.4 - HITL matrix, §8 confidence signal, §9 audit stream, §7 contract - schema are all *locked* — the snapshot preserves them as the v1.0 - record. The authored schema files (Waves 2-4) formalize these - sections; the snapshot is the prose, the schemas are the contract. + 1. **JSON IAM policy document** — `Version: "2012-10-17"`, + `Statement: [...]`. This is the AWS IAM policy language, not JSON + Schema. + 2. **Statement 1 — S3 ALLOW** — + ```json + { + "Sid": "SpikeS3StateBucketAccess", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*" + ] + } + ``` + Both the bucket ARN (for `ListBucket`) and the object ARN prefix + (for `PutObject`/`GetObject`/`DeleteObject`) — AWS requires both. + The account id `581513795199` is **concrete** (the orchestrator + verified it via `sts.get_caller_identity()` → + `arn:aws:iam::581513795199:root`). The bucket name follows the + operational-parameter template `acdl-tfstate--us-east-1` + concretized with the real account id. + 3. **Statement 2 — DynamoDB ALLOW** — + ```json + { + "Sid": "SpikeDynamoDBOutboxAccess", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + } + ``` + The outbox table ARN. D-044 locks the table name `acdl-outbox` + (PK `contractId`, SK `eventType#eventTs`, `PAY_PER_REQUEST`). + 4. **Statement 3 — STS ALLOW** — + ```json + { + "Sid": "SpikeStsGetCallerIdentity", + "Effect": "Allow", + "Action": "sts:GetCallerIdentity", + "Resource": "*" + } + ``` + `sts:GetCallerIdentity` does not support resource-level perms + (AWS requires `Resource: "*"`); this is the verify-step permission. + 5. **Statement 4 — explicit Deny-everything-else (least privilege)** — + ```json + { + "Sid": "SpikeDenyEverythingElse", + "Effect": "Deny", + "Action": "*", + "NotResource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + ] + } + ``` + An explicit `Deny` with `Action: "*"` + `NotResource` listing the + above ARNs. This enforces least privilege: the user can *only* touch + the state bucket + the outbox table + call `GetCallerIdentity`; + everything else is denied. (Note: `sts:GetCallerIdentity` is + allowed separately in Statement 3; the Deny's `NotResource` + excludes the S3 + DynamoDB ARNs but does not list `*` for STS — STS + `GetCallerIdentity` is not resource-scoped, so the Deny's + `NotResource` does not affect it. The explicit Deny's purpose is to + block S3/DynamoDB access to *other* buckets/tables + all non-S3/ + non-DynamoDB services. This is the security contract + security-engineer reviews.) + 6. **NO `terraform apply` permission** — the spike is plan-only (Out + of Scope table in REQUIREMENTS.md + PROJECT.md operational + parameter "Spike `terraform` command = `plan` only"). The policy + does not grant any IAM permission to create/modify resources beyond + the state bucket + outbox table. The user cannot run `terraform + apply` against arbitrary AWS because it has no permissions on any + resource type other than S3 (the state bucket) + DynamoDB (the + outbox). `terraform plan` only reads (with the AWS provider + querying resources the user has no access to — those reads return + empty/forbidden, which is fine for the spike's `l1-s3` plan that + only declares a single new bucket). + 7. **`$comment`-equivalent** — JSON has no comments; include a + `_doc` key at the top level (AWS IAM ignores unknown top-level keys) + with the text: "ACDL spike runner scoped policy (REQ-23b, D-039). + Least privilege: S3 on the state bucket + DynamoDB on the outbox + table + sts:GetCallerIdentity; explicit Deny everything else. NO + terraform apply (plan-only spike). The account id 581513795199 is + concrete (verified via sts.get_caller_identity). The bucket name + follows the operational parameter template + acdl-tfstate--us-east-1. v1.2 replaces this user+key + with an OIDC role + trust policy (blocked on go-gitea/gitea#36988)." - **Source citations:** - - `docs/architecture.md` (upstream v0.2 — the source of truth for how). - - `PROJECT.md` "Open-decision resolutions" table (the 11 resolutions). - - `PROJECT.md` decisions D-034..D-046. - - `ARCHITECTURE.md` §status (the ACDL-repo operating copy, already - notes "finalizes it to v1.0 in Phase 07"). + - `PROJECT.md` D-039 (per-run-rotated long-lived key waiver; OIDC + deferred to v1.2). + - `PROJECT.md` operational parameters (region us-east-1; state bucket + `acdl-tfstate--us-east-1`; lock table `acdl-tflock` — + NOTE: D-P08-1 below consolidates the lock table into `acdl-outbox`, + so the policy grants DynamoDB on `acdl-outbox`, not `acdl-tflock`). + - `PROJECT.md` D-044 (outbox `PAY_PER_REQUEST`, PK `contractId`, SK + `eventType#eventTs`, TTL `expire_at = now+365d`). + - `ARCHITECTURE.md` §12.3 (state storage S3 + DynamoDB single-region) + + §12.5 (execution layer; long-lived creds forbidden — D-039 waiver + for the spike). + - `REQUIREMENTS.md` REQ-23 (re-interpreted) + Out of Scope table + ("Terraform apply — out of scope; plan only"). - **Commit message:** - `phase: 7, status: plan-as-execute, persona: lead-developer, task: T-7.1` - (followed by a docs commit: - `docs(P07): snapshot architecture to v1.0 (REQ-16); 11 decisions resolved`) + `phase: 8, status: plan-as-execute, persona: security-engineer, task: T-8.1` + (then `feat(P08): author spike_runner_policy.json (REQ-23b; least-privilege; D-039)`) - **Self-verify:** - - [ ] `docs/architecture-v1.0.md` exists. - - [ ] First status line says `v1.0` (not `v0.2`). - - [ ] All 11 open-decision IDs appear with `✅ RESOLVED` (grep each: - `W1.A`, `W1.B`, `W2.A`, `W3.D`, `W3.E`, `BA.A`, `BA.B`, `BA.C`, - `BA.D`, `BA.E`, `BA.F`, `Q1.3`). - - [ ] `gitea-runner` appears (D-046 applied); `act_runner` only in a - "formerly" note. - - [ ] §15 table lists all 6 authored files. + - [ ] `python3 -c "import json; json.load(open('terraform/bootstrap/spike_runner_policy.json'))"` + exits 0 (valid JSON). + - [ ] `Version` is `"2012-10-17"`. + - [ ] 4 statements: S3 Allow, DynamoDB Allow, STS Allow, Deny. + - [ ] The Deny statement has `Action: "*"` + `NotResource` listing the + S3 + DynamoDB ARNs (not STS — STS GetCallerIdentity is not + resource-scoped). + - [ ] No `terraform` or `iam:` or `ec2:` actions in any Allow + statement (least privilege — the user cannot create/modify + infra). + - [ ] Account id `581513795199` is concrete in all ARNs. + - [ ] Bucket name `acdl-tfstate-581513795199-us-east-1` matches the + operational parameter template. + - [ ] DynamoDB table ARN ends with `table/acdl-outbox` (D-P08-1 + consolidated table). --- -### Wave 2 — platform-engineer + backend-engineer (parallel): REQ-17 IR + REQ-22 contract schema +### Wave 2 — platform-engineer: REQ-23a state backend + REQ-23b IAM user -Two independent JSON Schemas, different owners, parallelizable. Both -are JSON Schema draft 2020-12, both live under `schemas/`, both must -`python3 -m jsonschema` validate. +Two files, both boto3, both idempotent, owned by platform-engineer. +T-8.4 (`create_iam_user.py`) reads the Wave 1 policy file. The state +backend (T-8.3) is independent of the IAM user but both are in Wave 2 +because they are the AWS resource creation layer; they can be authored +in parallel after T-8.1 lands. -#### T-7.2 — Author `schemas/ir.schema.json` (Target Stack IR) +#### T-8.2 — Author `terraform/bootstrap/create_state_backend.py` header + contract -- **Owner:** platform-engineer (territory: `modules-ir/**`, - `adapters/terraform/**`, the IR; `schemas/` co-owned with - backend-engineer — platform-engineer is the IR author per PERSONAS.md - conflict resolution). -- **Files owned:** `schemas/ir.schema.json` (new). +(This is folded into T-8.3 as a single file — listed here only to make the +wave structure explicit. T-8.3 is the implementation; there is no separate +T-8.2 file.) + +#### T-8.3 — Author `terraform/bootstrap/create_state_backend.py` + +- **Owner:** platform-engineer (territory: `terraform/**` state backend + per PERSONAS.md). +- **Files owned:** `terraform/bootstrap/create_state_backend.py` (new). - **What the file must contain** (cite sources): - 1. **JSON Schema draft 2020-12** — `$schema: - "https://json-schema.org/draft/2020-12/schema"`, `$id: - "https://acdl.cloudinit.dev/schemas/ir.schema.json"`, `title: - "ACDL Target Stack IR"`. - 2. **Top-level shape** — `required: [version, stack, resources]`, - `properties`: `version` (semver string), `stack` (name/kind/depth), - `resources` (array of resource, minItems 1), `relationships` (array - of relationship, optional). - 3. **`stack`** — `name` pattern `^l[12]-[a-z][a-z0-9-]*$`, `kind` enum - `[l1, l2]`, `depth` integer min 1 max 5 (ARCHITECTURE.md §3 "max - depth 5"). - 4. **`resource` ($defs/resource)** — required `[id, type, module, - inputs]`; `id` pattern `^[a-z][a-z0-9-]*$`; `type` string - (substrate-agnostic, e.g. `aws:s3:bucket` — the *IR* type, not a - Terraform address); `module` pattern - `^l1-[a-z][a-z0-9-]*@\d+\.\d+\.\d+$` (L1 registry reference - `name@semver` per W3.D); `parent` (string, absent for root — single - parent per child per ARCHITECTURE.md §12.1); `inputs` (object, - additionalProperties free-form — typed by the L1 module's declared - inputs, validated at contract→IR resolution); `outputs` (object of - outputSpec — typed output contract; the adapter translates to - Terraform outputs); `nfrs` (object, opaque to the adapter, consumed - by the confidence signal's NFR input). - 5. **`outputSpec` ($defs/outputSpec)** — required `[type]`; `type` - string (IR type, e.g. `string`, `arn`, - `ref:.`); `description` optional. - 6. **`relationship` ($defs/relationship)** — required `[from, to, kind]`; - `from`/`to` resource ids; `kind` enum `[parent, depends_on, - uses_output]` with a description noting v1 uses `parent` + - `uses_output` only (ARCHITECTURE.md §12.1); `shared_keyword` - (string, reserved for v2 multi-relationship, unused in v1 — - A-3.2). - 7. **Substrate-agnostic invariant** — the schema body must contain - **no Terraform-specific terms** (`variable`, `output`, `module`, - `resource` as Terraform blocks, `aws_` provider prefixes in the - schema keywords). The `type` field values are IR types - (`aws:s3:bucket`), not Terraform resource types (`aws_s3_bucket`). - The adapter (Phase 09) does the translation; the IR is neutral. - 8. **Why this round-trips to Terraform (v1)** — include a - `$comment` at the top: "v1 ships one adapter (Terraform). The IR is - nearly isomorphic to Terraform in v1 (ARCHITECTURE.md §12.1); the - adapter compiles resource.module → module block, resource.inputs → - variable + arg, resource.outputs → output, relationship.kind= - uses_output → interpolation, relationship.kind=parent → composition - ordering hint. As more adapters appear (v2+), the IR gains - expressiveness; the L1 content + contract YML + thin-composition - tree do not change." + 1. **Module docstring** — "Bootstrap the ACDL spike state backend + (REQ-23a): create the S3 state bucket (versioning enabled) + the + DynamoDB outbox table (PAY_PER_REQUEST, PK contractId, SK + eventType#eventTs). Idempotent: skips resources that already exist. + Uses boto3 with the bootstrap root key from env vars + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID + ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + (NEVER committed). Region us-east-1 (operational parameter). Writes + a .bootstrap_state.json marker with {account_id, bucket_name, + table_name, region, created_at}. D-044 locks the outbox shape; + D-P08-1 consolidates the lock table into acdl-outbox (one table for + both locking + outbox)." + 2. **Imports** — `import boto3, os, json, datetime, sys`. No + third-party deps beyond boto3. + 3. **Constants** — + - `REGION = "us-east-1"` (operational parameter). + - `ACCOUNT_ID = "581513795199"` (concrete — verified; could also be + derived at runtime via `sts.get_caller_identity()`, but the + orchestrator has confirmed it, so hardcoding is fine + the verify + script asserts it). + - `BUCKET_NAME = f"acdl-tfstate-{ACCOUNT_ID}-{REGION}"` (operational + parameter template, concretized). + - `TABLE_NAME = "acdl-outbox"` (D-044 + D-P08-1 consolidated table). + 4. **`_client(service)`** — build a boto3 client with the bootstrap + creds from env vars (`AWS_ACCESS_KEY_ID` from + `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID`, etc.) + region. Raise a clear + error if the env vars are missing. + 5. **`create_s3_bucket()`** — + - `head_bucket(Bucket=BUCKET_NAME)` first; if it succeeds, the bucket + exists → skip creation, log "S3 bucket already exists". + - If `head_bucket` raises `ClientError` with `404`/`NoSuchBucket`, + create the bucket. **Note:** for `us-east-1` the + `CreateBucketConfiguration` `LocationConstraint` must **NOT** be + set (us-east-1 is the default; setting it for us-east-1 raises + `InvalidLocationConstraint`). For any other region you would set + `LocationConstraint: `. The script asserts `REGION == + "us-east-1"` at the top + comments this gotcha. + - Enable **versioning** on the bucket + (`put_bucket_versioning(Bucket=..., VersioningConfiguration={...})` + with `Status="Enabled"`) — state-file safety (ARCHITECTURE.md §12.3 + S3 state storage; versioning is the minimum safety for the spike). + - Enable **server-side encryption** (SSE-S3) via + `put_bucket_encryption` — minimum security posture for the state + file (which may contain sensitive outputs). Optional but + recommended; if added, document it. + - Block public access (`put_public_access_block` with all four + blocks `True`) — the state bucket must never be public. + 6. **`create_dynamodb_table()`** — + - `describe_table(TableName=TABLE_NAME)` first; if it succeeds, the + table exists → skip creation, log "DynamoDB table already exists". + - If `describe_table` raises `ResourceNotFoundException`, create the + table with: + - `TableName=TABLE_NAME`. + - `BillingMode="PAY_PER_REQUEST"` (D-044 — on-demand, zero-cost + at idle for the spike's single dev submission). + - `AttributeDefinitions=[ + {"AttributeName":"contractId","AttributeType":"S"}, + {"AttributeName":"eventType#eventTs","AttributeType":"S"}]`. + - `KeySchema=[ + {"AttributeName":"contractId","KeyType":"HASH"}, + {"AttributeName":"eventType#eventTs","KeyType":"RANGE"}]` + (PK `contractId`, SK `eventType#eventTs` per D-044). + - Enable **TTL** via a separate `update_time_to_live` call after + creation: `TimeToLiveSpecification={AttributeName="expire_at", + Enabled=True}`. The writer sets `expire_at = now + 365d` + (D-044). Note: TTL attribute is `expire_at` (epoch seconds), not + ISO-8601 — the outbox writer (Phase 10) must set it as an + integer epoch. Document this in the docstring. + - Wait for the table to become `ACTIVE` + (`table.wait_until_exists()` or a `describe_table` poll loop). + 7. **`write_bootstrap_state_marker()`** — write + `terraform/bootstrap/.bootstrap_state.json` (gitignored — T-8.7) + with: + ```json + { + "account_id": "581513795199", + "bucket_name": "acdl-tfstate-581513795199-us-east-1", + "table_name": "acdl-outbox", + "region": "us-east-1", + "created_at": "", + "versioning": true, + "ttl_attribute": "expire_at" + } + ``` + This is bookkeeping, NOT secrets. The verify script (T-8.6) asserts + it exists + is gitignored. + 8. **`main()`** — call `_client` checks, `create_s3_bucket()`, + `create_dynamodb_table()`, `write_bootstrap_state_marker()`, print + a summary to stdout. Exit 0 on success, non-zero on any AWS error + (with a clear message). + 9. **Idempotency** — both `create_*` functions skip-if-exists. Re-running + the script is safe (the bootstrap state marker is overwritten with + a fresh `created_at` — that's fine; it's a "last bootstrap ran at" + marker, not a "first creation" record). + 10. **No secrets to stdout** — the script does NOT print the bootstrap + key. It prints the bucket name + table name + account id (all + non-secret). The bootstrap key is in env vars only. - **Source citations:** - - `ARCHITECTURE.md` §12.1 (substrate abstraction — locked commitments: - resources typed inputs/outputs/NFRs, relationships single parent per - child, composition tree max-depth-5, policy hooks). - - `RESEARCH.md` TARGET 3 (v1 IR shape sketch — the executor uses this - as the basis; the sketch is a *recommendation*, the authored schema - must match ARCHITECTURE.md's locked commitments and be - self-contained). - - `PROJECT.md` W3.D (L1/L2 standard versioning — `name@semver` pin - model, MAJOR bump = new registry entry, 12-month deprecation - window). -- **Commit message:** - `phase: 7, status: plan-as-execute, persona: platform-engineer, task: T-7.2` - (then `feat(P07): author schemas/ir.schema.json (REQ-17)`) -- **Self-verify:** - - [ ] `python3 -m jsonschema schemas/ir.schema.json` exits 0 (schema is - itself valid JSON Schema). - - [ ] A minimal valid IR instance validates: `{version:"1.0.0", - stack:{name:"l2-static-asset",kind:"l2",depth:1}, - resources:[{id:"s3",type:"aws:s3:bucket", - module:"l1-s3@1.0.0",inputs:{bucket_name:"x",region:"us-east-1"}}]}` - — write this to a temp file and validate against the schema. - - [ ] No `aws_s3_bucket` / Terraform-block keywords in the schema body. - - [ ] `depth` max is 5; `module` pattern enforces `name@semver`; - `relationship.kind` enum includes `parent` + `uses_output`. - - [ ] `shared_keyword` is reserved (present, unused in v1). - -#### T-7.3 — Author `schemas/contract.schema.json` - -- **Owner:** backend-engineer (territory: `schemas/**` contract + IR + - PolicyCheckResult per PERSONAS.md; owns the contract schema + the - contract→IR resolution). -- **Files owned:** `schemas/contract.schema.json` (new). -- **What the file must contain** (cite sources): - 1. **JSON Schema draft 2020-12** — `$schema: - "https://json-schema.org/draft/2020-12/schema"`, `$id: - "https://acdl.cloudinit.dev/schemas/contract.schema.json"`, - `title: "ACDL Contract"`. - 2. **Top-level required** — `[stack, environment]` (dev's minimum per - W3.E). `stack` pattern `^l2-[a-z][a-z0-9-]*$` (L2 reference); - `environment` enum `[dev, qa, prod, dr]` (no `staging` — Path A - locked, ARCHITECTURE.md §5). - 3. **`inputs`** — object, `additionalProperties` type - `[string, number, boolean]`, always optional (W3.E: "`inputs` - always optional"). Description: "L2-level parameter map; free-form - in v1, typed per-L1 in v1.2" (A-9.2). - 4. **`validation`** — object with `e2eSuite` (string, ref to the e2e - suite) + `loadTest` (string, ref to the load test). Mandatory in qa - via the `allOf` conditional below. - 5. **`runbook`, `dashboard`, `oncall`** — strings, mandatory in prod. - 6. **`drDrillRef`** — string, mandatory in dr. - 7. **`profile`** — enum `[developer, agentic]`, default `developer`. - The L3B marker (ARCHITECTURE.md §5 "profile: agentic unlocks L3B - fields"). - 8. **`naturalLanguageIntent`, `confidenceAtSubmission`, `agentTrace`** - — the L3B fields, optional everywhere (W3.E). When `profile: - agentic`, `naturalLanguageIntent` becomes required (via `allOf`). - `confidenceAtSubmission` is a number in `[0, 1]`. - 9. **`supersedes`** — string, `format: uuid`, optional. Description: - "prior contractId this re-submission replaces (after rejection — - ARCHITECTURE.md §10.6)". - 10. **`allOf` conditionals** (W3.E per-env mandatory table): - - `if environment == qa → then required: [validation]` (and - within `validation`, require `e2eSuite` + `loadTest`). - - `if environment == prod → then required: [runbook, dashboard, oncall]`. - - `if environment == dr → then required: [drDrillRef]`. - - `if profile == agentic → then required: [naturalLanguageIntent]`. - 11. **`$comment`** — "Per-env mandatory inputs per W3.E (PROJECT.md). - dev requires stack+environment; qa adds validation.e2eSuite + - validation.loadTest; prod adds runbook+dashboard+oncall; dr adds - drDrillRef. inputs always optional. profile: agentic fields - optional everywhere (naturalLanguageIntent required when profile - is agentic)." -- **Source citations:** - - `ARCHITECTURE.md` §7 (contract schema — W3.E per-env mandatory - table). - - `RESEARCH.md` TARGET 9 (contract schema sketch — the executor uses - this as the basis; must be self-contained + match ARCHITECTURE.md). - - `PROJECT.md` W3.E (the locked per-env mandatory/optional table). - - `PROJECT.md` W2.A (tag for dev/qa, SHA for prod — not a schema field - but informs the `stack` reference resolution in the CLI, noted in - the `$comment`). -- **Commit message:** - `phase: 7, status: plan-as-execute, persona: backend-engineer, task: T-7.3` - (then `feat(P07): author schemas/contract.schema.json (REQ-22)`) -- **Self-verify:** - - [ ] `python3 -m jsonschema schemas/contract.schema.json` exits 0. - - [ ] The spike contract - `{stack:"l2-static-asset",environment:"dev",inputs:{bucket_name:"acdl-spike-bucket",region:"us-east-1"}}` - validates (dev requires only stack+environment). - - [ ] A qa contract missing `validation` *fails* validation. - - [ ] A prod contract missing `runbook` *fails*. - - [ ] A dr contract missing `drDrillRef` *fails*. - - [ ] An agentic contract missing `naturalLanguageIntent` *fails*; - an agentic contract with it *passes*. - - [ ] `environment` enum does NOT include `staging`. - ---- - -### Wave 3 — security-engineer: REQ-18 + REQ-20 + REQ-21 - -One persona, five files, one wave (sequential within the wave). The -Checkov adapter (T-7.5) depends on the PolicyCheckResult schema (T-7.4); -the SoD module (T-7.8) depends on the outbox shape in the ledger design -(T-7.6). Order within the wave: T-7.4 → T-7.5 → T-7.6 → T-7.7 → T-7.8. - -#### T-7.4 — Author `schemas/policy_check_result.schema.json` - -- **Owner:** security-engineer (owns `adapters/terraform/policy/**`; the - schema is co-authored with backend-engineer who owns `schemas/**` - contract + IR + PolicyCheckResult — security-engineer owns the - *adapter*, the schema is the contract both share. security-engineer - authors it here because the adapter's shape is the security concern - + the severity enum is the security-engineer's territory per the - conflict-resolution note on confidence_signal co-ownership). -- **Files owned:** `schemas/policy_check_result.schema.json` (new). -- **What the file must contain** (cite sources): - 1. **JSON Schema draft 2020-12** — `$schema`, `$id: - "https://acdl.cloudinit.dev/schemas/policy_check_result.schema.json"`, - `title: "ACDL PolicyCheckResult"`. - 2. **Canonical shape** — exactly the JSON shape from - `ARCHITECTURE.md` §12.6: - - `contractId` (string, format uuid). - - `evaluatedAt` (string, format date-time — ISO-8601). - - `engine` (enum `[checkov, kyverno, opa]`). - - `ruleId` (string — e.g. `CKV_AWS_24`, `KYVERNO_NO_PRIVILEGED`, - `ACDL_TAG_NAMING`). - - `severity` (enum `[critical, high, medium, low, info]`). - - `result` (enum `[pass, fail, skipped, error]`). - - `message` (string, human-readable). - - `evidence` (object, additionalProperties true — engine-specific - payload, opaque to the confidence signal per §12.6). - - `resourceRef` (string — IR-typed resource identifier). - 3. **Required** — all of: `contractId, evaluatedAt, engine, ruleId, - severity, result, message, resourceRef` (evidence is optional — - some engines may omit; the confidence signal does not read it). - 4. **`$comment`** — "Canonical PolicyCheckResult (ARCHITECTURE.md - §12.6). The confidence signal (platform/confidence_signal.py) - consumes a list of these as its `policy` input; it is - engine-agnostic. The severity enum drives the severity→penalty - mapping (critical hard-override, high -0.2, medium -0.05, low - -0.01, info 0.0). Adapters (checkov_adapter.py, future - kyverno_adapter) translate native engine output to this shape." -- **Source citations:** - - `ARCHITECTURE.md` §12.6 (the canonical shape — copy verbatim into - the schema). - - `RESEARCH.md` TARGET 4 (PolicyCheckResult + Checkov adapter). -- **Commit message:** - `phase: 7, status: plan-as-execute, persona: security-engineer, task: T-7.4` - (then `feat(P07): author schemas/policy_check_result.schema.json (REQ-18 schema half)`) -- **Self-verify:** - - [ ] `python3 -m jsonschema schemas/policy_check_result.schema.json` - exits 0. - - [ ] A valid instance validates: - `{contractId:"",evaluatedAt:"2026-07-21T12:00:00Z",engine:"checkov",ruleId:"CKV_AWS_24",severity:"high",result:"fail",message:"SG 0.0.0.0/0 on 22",evidence:{resource:"aws_security_group.r1"},resourceRef:"r1"}`. - - [ ] `severity` enum has exactly `[critical, high, medium, low, info]`. - - [ ] `result` enum has exactly `[pass, fail, skipped, error]`. - - [ ] `engine` enum has exactly `[checkov, kyverno, opa]`. - -#### T-7.5 — Author `adapters/terraform/policy/checkov_adapter.py` - -- **Owner:** security-engineer (territory: - `adapters/terraform/policy/**`). -- **Files owned:** `adapters/terraform/policy/checkov_adapter.py` (new); - create `adapters/terraform/policy/__init__.py` if needed for import. -- **What the file must contain** (cite sources): - 1. **Module docstring** — "Translate Checkov JSON output to ACDL - PolicyCheckResult records (schemas/policy_check_result.schema.json). - Reads Checkov's JSON output (one framework key, e.g. - `terraform_plan`), emits a list of PolicyCheckResult dicts. - Run Checkov with `--soft-fail` so Checkov never exits non-zero; the - confidence signal decides the gate, not Checkov's exit code." - 2. **`RULE_MAP`** — dict mapping Checkov rule IDs to - `(ACDL L2 check name, default severity when Checkov omits one)`, - per RESEARCH.md TARGET 4 table: - - `CKV_AWS_41, CKV_AWS_45, CKV_AWS_46` → `("secrets-in-plaintext", "high")`. - - `CKV_AWS_20, CKV_AWS_57` → `("public-ingress", "high")` (S3 public ACL). - - `CKV_AWS_24, CKV_AWS_25` → `("public-ingress", "medium")` (SG 0.0.0.0/0). - - `CKV_AWS_1` → `("iam-wildcard", "high")`. - - `CKV_AWS_40` → `("iam-wildcard", "medium")`. - - `CKV_AWS_7, CKV_AWS_33` → `("kms-key-reference", "medium")`. - Unknown Checkov rule IDs → `(rule_id, "info")` fallback. - 3. **`_iso8601_now()`** — UTC now in ISO-8601 `Z` suffix. - 4. **`_to_pcr(checkov_record, contract_id, result_str)`** — map a - Checkov check record to a PolicyCheckResult dict: - - `contractId` = the contract_id arg. - - `evaluatedAt` = `_iso8601_now()`. - - `engine` = `"checkov"`. - - `ruleId` = `checkov_record["check_id"]`. - - `severity` = `checkov_record.get("severity", default_sev).lower()` - (prefer Checkov's severity if present; fall back to RULE_MAP - default — A-4.1: spike runs without Prisma Cloud API key). - - `result` = `{"PASSED":"pass","FAILED":"fail", - "SKIPPED":"skipped"}.get(result_str, "error")`. - - `message` = `checkov_record.get("check_name", "")`. - - `evidence` = `{"file_path":..., "resource":..., - "resource_address":..., "code_block":...}` (engine-specific, - opaque to the signal). - - `resourceRef` = `checkov_record.get("resource_address") or - checkov_record.get("resource", "")` (defensive — A-4.1 0.15 - residual for field-name drift). - 5. **`adapt(checkov_json_path, contract_id)`** — read the Checkov JSON, - iterate frameworks, for each framework's `results` body iterate - `passed_checks` (→ "PASSED"), `failed_checks` (→ "FAILED"), - `skipped_checks` (→ "SKIPPED"), build the list. Tolerate both - `{framework: {results: {...}}}` and `{framework: {...}}` shapes - (Checkov minor-version drift). - 6. **`_emit_tag_naming_skipped(contract_id)`** — per D-043, emit a - single `PolicyCheckResult` with `ruleId: "ACDL_TAG_NAMING"`, - `result: "skipped"`, `severity: "info"`, `engine: "checkov"`, - `message: "tag/naming check deferred to v1.2 (D-043)"`. This - guarantees the confidence signal's `policy` input is non-empty even - if Checkov finds nothing (the "all six inputs present" dev gate). - 7. **`adapt()` appends the tag/naming skipped record** to the output - list after the Checkov pass. - 8. **`__main__`** — `checkov_path, contract_id = sys.argv[1], - sys.argv[2]`; `print(json.dumps(adapt(...), indent=2))`. CLI: - `python3 adapters/terraform/policy/checkov_adapter.py checkov.json - `. - 9. **No external deps** — stdlib only (`json`, `sys`, `datetime`). - Checkov is invoked by the workflow, not by this module; this module - reads Checkov's JSON output file. - 10. **Spike-scope note** — a module-level comment: "Spike scope (D-043): - tag/naming is a single SKIPPED record. A custom Checkov YAML rule - for tag presence lands in v1.2." -- **Source citations:** - - `ARCHITECTURE.md` §12.4 (policy toolchain — Checkov for the L2 - checks + tag/naming) + §12.6 (PolicyCheckResult shape). - - `RESEARCH.md` TARGET 4 (Checkov adapter sketch + rule→severity map - — the executor uses this as the basis; must be self-contained). - - `PROJECT.md` D-043 (tag/naming deferred — emit SKIPPED). -- **Commit message:** - `phase: 7, status: plan-as-execute, persona: security-engineer, task: T-7.5` - (then `feat(P07): author checkov_adapter.py (REQ-18 adapter half; D-043 tag/naming skipped)`) -- **Self-verify:** - - [ ] `python3 -m py_compile - adapters/terraform/policy/checkov_adapter.py` exits 0. - - [ ] A synthetic Checkov JSON fixture (a single failed CKV_AWS_24) - produces a PolicyCheckResult with `severity:"medium"`, - `result:"fail"`, `ruleId:"CKV_AWS_24"` + the appended - `ACDL_TAG_NAMING` skipped record. - - [ ] Output validates against `schemas/policy_check_result.schema.json` - (each record). - - [ ] `RULE_MAP` has all 11 Checkov rule IDs from RESEARCH.md TARGET 4. - - [ ] No non-stdlib imports. - -#### T-7.6 — Author `platform/audit_ledger_design.md` - -- **Owner:** security-engineer (territory: - `platform/audit_ledger_design.md`). -- **Files owned:** `platform/audit_ledger_design.md` (new). -- **What the file must contain** (cite sources): - 1. **Header** — "ACDL Tiered Audit Ledger Design (REQ-20). Status: - design authored in Phase 07; spike implements the v1.0 hash chain + - outbox write (D-041); v1.2 builds out Object Lock + JWS + async - worker + DLQ." - 2. **Tiered ledger overview** — the three tiers: - - **Cold tier (source of truth):** S3 with Object Lock in - *compliance mode*, 7-year retention (ARCHITECTURE.md §9, RESEARCH - TARGET 7). No one — including root — can delete/overwrite until - retention expires. - - **Hot tier (query index):** `acdl-evidence` audit repo (the v1.0 - demo's path; unchanged). Not part of the chain; a queryable - mirror. The evidence UI (`evidence-ui/index.html`) reads it. - - **Outbox (write path):** DynamoDB, RPO=0 (synchronous write before - submission ack). Single-region in v1 (us-east-1). - 3. **Spike scope (D-041) — what the spike implements:** - - DynamoDB outbox: table `acdl-outbox`, `PAY_PER_REQUEST` (D-044), - PK `contractId`, SK `eventType#eventTs`, TTL `expire_at` = now + - 365d (1-year storage per §8). - - `prev_event_hash` chain: SHA-256 over canonical JSON - (`json.dumps(event, sort_keys=True, separators=(",",":"))`), - lifted from the v1.0 demo's `evidence_writer.py`. Auto-genesis: - first event has `prev_hash="GENESIS"`. - - Synchronous write via boto3 `put_item` (strong-consistent by - default). No separate async worker / DLQ in the spike (RTO = - workflow re-run; A-5.2). - - Mirror to `acdl-evidence` (unchanged from v1.0 — the finalize - step commits `audit.json`). - - Spike evidence event shape (RESEARCH TARGET 7): - `{seq, ts, stage, event, prev_hash, hash, contractId, - environment, stack, score, band}`. - 4. **v1.2 build-out — what Phase 07 designs but the spike defers:** - - **S3 Object Lock:** bucket `acdl-evidence-lock-`, - Object Lock enabled at creation, compliance mode, 7-yr retention - (`RetainUntilDate` = now + 7y). The outbox→S3 path is an async - worker that reads from the outbox and writes to Object Lock. - - **JWS detached signature (RFC 7515):** the event payload is - canonical-JSON-serialized, SHA-256 hashed, signed with a private - key; the signature is stored *detached* alongside the payload. - Signing key = **platform-level KMS key** (not per-contract — - RESEARCH A-7.2), rotated **quarterly**. The `jws` field is added - to the event shape in v1.2. - - **Async worker + DLQ:** a Lambda (or Gitea Actions scheduled - workflow) reads the outbox, writes to S3 Object Lock, signs with - KMS. DLQ = an SQS dead-letter queue for failed writes. RTO = DLQ - replay. - - **Daily checkpoints (§9):** a daily job reads the last event hash - and writes a "checkpoint" event to the ledger (+ optionally to a - public notarization service). The spike runs in minutes, not - days — no checkpoint in spike. - 5. **JWS vs chain — orthogonality note:** the `prev_event_hash` chain - gives ordering/tamper-evidence *within* the log; JWS gives - authenticity *per event*. The chain is spike-scope; JWS is v1.2. - 6. **Outbox item shape (full, spike+v1.2)** — the DynamoDB item: - - PK `contractId` (UUID). - - SK `eventType#eventTs` (e.g. `POLICY_CHECKED#2026-07-21T12:00:00Z`). - - `payload` (the event body — hash-chained in spike, JWS-signed in - v1.2). - - `prev_event_hash` (chain link). - - `hash` (this event's SHA-256). - - `approver_qa` (Gitea username of the QA approver; empty in - dev-only spike; populated on qa-promotion — D-042). - - `approver_prod` (SRE username; empty in spike). - - `environment`, `stack`, `score`, `band`. - - `expire_at` (TTL = now + 365d). - - v1.2 only: `jws` (detached signature), `checkpoint_ref`. - 7. **RPO/RTO table:** - - Spike: RPO=0 (sync outbox write), RTO=workflow re-run. - - v1.2: RPO=0 (sync outbox), RTO=async worker DLQ replay. - 8. **Decision trail** — cite D-041 (spike scope), D-044 (outbox mode + - TTL), D-042 (approver identities in outbox). -- **Source citations:** - - `ARCHITECTURE.md` §9 (audit + evidence stream — locked: S3 Object - Lock compliance 7-yr + DynamoDB outbox RPO=0 + JWS + chain + daily - checkpoints). - - `RESEARCH.md` TARGET 7 (tiered audit ledger — S3 Object Lock + - JWS + chain; spike minimum). - - `RESEARCH.md` TARGET 5 (DynamoDB outbox pattern — RPO=0, on-demand, + - `ARCHITECTURE.md` §12.3 (state storage S3 + DynamoDB single-region). + - `ARCHITECTURE.md` §12.5 (execution layer; long-lived creds forbidden — + D-039 waiver; the bootstrap root key is the one-time exception). + - `PROJECT.md` D-044 (outbox PAY_PER_REQUEST, PK contractId, SK + eventType#eventTs, TTL expire_at = now+365d). + - `PROJECT.md` operational parameters (region us-east-1; state bucket + `acdl-tfstate--us-east-1`). + - `RESEARCH.md` TARGET 5 (DynamoDB outbox pattern, RPO=0, on-demand, PK/SK, TTL). - - `PROJECT.md` D-041 (spike scope = hash chain + outbox), - D-044 (outbox mode + TTL), D-042 (approver identities in outbox). - **Commit message:** - `phase: 7, status: plan-as-execute, persona: security-engineer, task: T-7.6` - (then `docs(P07): author platform/audit_ledger_design.md (REQ-20; D-041 spike scope)`) + `phase: 8, status: plan-as-execute, persona: platform-engineer, task: T-8.3` + (then `feat(P08): author create_state_backend.py (REQ-23a; S3+DynamoDB; D-044; D-P08-1)`) - **Self-verify:** - - [ ] File exists, non-empty markdown. - - [ ] "compliance mode" + "7-year" appear (S3 Object Lock locked). - - [ ] "RPO = 0" / "RPO=0" appears. - - [ ] "JWS" + "detached" + "KMS" + "quarterly" appear (v1.2 design). - - [ ] "prev_event_hash" + "canonical JSON" + "SHA-256" appear (chain). - - [ ] "daily checkpoint" appears. - - [ ] "D-041", "D-044", "D-042" appear (decision trail). - - [ ] A clear "Spike scope" vs "v1.2 build-out" section boundary. + - [ ] `python3 -m py_compile terraform/bootstrap/create_state_backend.py` + exits 0. + - [ ] `boto3` is the only third-party import. + - [ ] The script reads `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` from env (grep for the env + var names). + - [ ] `us-east-1` `LocationConstraint` gotcha is commented (no + `CreateBucketConfiguration` for us-east-1). + - [ ] Versioning is enabled (`put_bucket_versioning`). + - [ ] DynamoDB `BillingMode="PAY_PER_REQUEST"`, PK `contractId`, SK + `eventType#eventTs`, TTL `expire_at`. + - [ ] Both `create_*` functions are skip-if-exists (idempotent). + - [ ] `.bootstrap_state.json` is written with the 7 keys above. -#### T-7.7 — Author `platform/hitl_matrix_design.md` +#### T-8.4 — Author `terraform/bootstrap/create_iam_user.py` -- **Owner:** security-engineer (territory: - `platform/hitl_matrix_design.md`). -- **Files owned:** `platform/hitl_matrix_design.md` (new). +- **Owner:** platform-engineer (creation) + security-engineer (policy + review — the policy file is T-8.1; this script reads it and attaches + it). platform-engineer authors the script; security-engineer reviews + the policy substitution logic. +- **Files owned:** `terraform/bootstrap/create_iam_user.py` (new). - **What the file must contain** (cite sources): - 1. **Header** — "ACDL Human-in-the-Loop Matrix + Separation-of-Duties - Design (REQ-21). Status: design authored in Phase 07; v1.2 wires - the gates. Spike (Phases 08-10) is dev-only; HITL is not exercised." - 2. **Gate model (§10.1)** — pre-execution gates. The contract is held - in "validated but not applied" until the human attests. qa, prod, dr - are attestation gates. No partial deployment to roll back on - rejection (qa, prod); dr is a separate deployment against a separate - cluster/region. - 3. **Gitea-specific gate mechanics (D-042)** — Gitea has **no - Environments API** and ignores `environment:` blocks (v1.0 D-013; - re-confirmed RESEARCH TARGET 8). The pre-execution gate is modeled - as a `workflow_dispatch` with approval inputs: - - qa gate: `workflow_dispatch` with `approve_qa: true`; the - dispatch run's `gitea.actor` is the QA approver. - - prod gate: `workflow_dispatch` with `approve_prod: true`; - `gitea.actor` is the SRE approver. - - dr gate: `workflow_dispatch` with `approve_dr: true`; same. - The approver identity of record = `gitea.actor` of the dispatch - run (D-042). There is no other approval-identity signal in Gitea. - 4. **Reviewer routing (§10.2)** — Gitea CODEOWNERS routes the right - reviewer to the right gate (qa → QA team; prod → SRE team; dr → - SRE team). CODEOWNERS routes; it does **not** enforce identity - distinctness (that's the outbox check in T-7.8). - 5. **Full 8-concern attestation matrix (§10.4)** — the complete table - from `docs/architecture.md` §10.4, lifted verbatim: - | Env | Concern | Evidence artifact | Freshness | Source | Attester | - | qa | Functional correctness | Last successful run of contract-declared validation.e2eSuite with pass rate ≥ 99% | Last 24h | Test runner declared in contract | QA | - | qa | Performance baseline | Load test report (k6/Gatling/Locust) showing p99 latency < declared NFR and throughput > declared minimum | Last 7d | Load test runner declared in contract | QA | - | qa | Security posture | Vulnerability scan (Trivy/Snyk/contract-declared) with no criticals/highs, signed by Security on-call | Last 24h | Security scanner + Security team signature | QA | - | qa | Contract NFRs | Platform-generated report: schema valid, NFR assertions within declared bounds | At submission | Platform contract validator | QA | - | prod | Operational readiness | Runbook published, dashboard exists, on-call rotation assigned, alerts configured | At submission, validated against last 30d history | Platform + SRE | SRE | - | prod | Incident response | Sev-1 runbook tabletop or live drill completed | Last 90d | SRE drill record | SRE | - | prod | Capacity / cost | FinOps forecast for next 30d within budget envelope, cost anomaly baseline stored, budget alert configured | Forecast valid for next 30d | FinOps + SRE | SRE | - | prod | Resilience | DR drill, chaos engineering report, backup verified | DR: 180d; chaos: 90d; backup: 30d | SRE + Platform | SRE | - | dr | dr-region deploy with the most recent prod-bound dr drill as canary evidence | dr drill report | Last 180d | SRE | SRE | - 6. **Timeout behavior (§10.5)** — the table: - | Time | State | Action | - | Submission | PENDING_ATTESTATION | Notify responsible team | - | 1 business day | PENDING_ATTESTATION_WARNING | Notify team + platform on-call (elevated path); emit PENDING_ATTESTATION_TIMEOUT_WARNING event | - | 2 business days | PENDING_ATTESTATION_AUTO_FREEZE | Auto-freeze; require re-submission; emit PENDING_ATTESTATION_AUTO_FREEZE event; new submission linked via `supersedes` | - Implementation: a Gitea `on: schedule` workflow (runs hourly) that - scans the outbox for `PENDING_ATTESTATION` events with `ts` older - than 1/2 business days and emits the warn/freeze events. Not - implemented in the spike (A-8.3). - 7. **Rejection and rollback (§10.6)** — rejection returns the contract - to `HELD` state with the rejection reason captured as a - `PROMOTION_REJECTED` event. The consumer fixes the cause and - re-submits; the new submission is linked to the rejected one via - `supersedes` (a contract-schema field — T-7.3). The audit chain is - extended, not torn up (the "Not a mutable audit log" anti-goal). - No partial deployment to roll back at any v1 gate. - 8. **Separation of duties (§10.3) — pointer to the .py** — a section - that describes the check sequence (dev→qa writes approver_qa to - outbox; qa→prod reads approver_qa + new gitea.actor; if equal → - block + emit SEPARATION_OF_DUTIES_VIOLATION + route halt artifact - to SRE on-call) and points at `platform/separation_of_duties.py` - (T-7.8) for the implementation. - 9. **Spike scope note** — the spike is dev-only (REQ-27 contract has - `environment: dev`), so HITL is not exercised. Phase 07 authors - the design; Phase 10's `verify_phase10.sh` does not assert HITL - behavior. v1.2 wires the gates. - 10. **Decision trail** — cite D-042 (approver identity = gitea.actor), - D-013 (v1.0 workflow_dispatch fallback, re-used). + 1. **Module docstring** — "Bootstrap the ACDL spike IAM user (REQ-23b): + create the IAM user acdl-spike-runner, attach the scoped inline + policy from spike_runner_policy.json, create an initial access key, + print the key to stdout for the orchestrator to capture. Idempotent: + if the user exists, skip user creation but ensure the inline policy + is attached; if no active access key exists, create one; if one + exists, report it (rotate_spike_key.sh handles rotation). Uses boto3 + with the bootstrap root key from env vars. D-039 spike waiver; the + user + key are deleted in v1.2 when OIDC lands." + 2. **Imports** — `import boto3, os, json, sys`. + 3. **Constants** — + - `REGION = "us-east-1"`. + - `ACCOUNT_ID = "581513795199"`. + - `USER_NAME = "acdl-spike-runner"` (per the orchestrator's spec). + - `POLICY_NAME = "acdl-spike-runner-policy"`. + - `POLICY_FILE = os.path.join(os.path.dirname(__file__), "spike_runner_policy.json")`. + 4. **`_client(service)`** — same pattern as T-8.3. + 5. **`create_user()`** — + - `get_user(UserName=USER_NAME)` first; if it succeeds, the user + exists → skip creation, log "IAM user already exists". + - If `get_user` raises `NoSuchEntity`, `create_user(UserName=...)`. + 6. **`attach_inline_policy()`** — + - Read `spike_runner_policy.json` (T-8.1). The policy's ARNs are + already concrete (`581513795199` + `us-east-1` are baked in by T-8.1 + — no substitution needed; the policy file is the source of truth). + (If the policy file used placeholders, this is where you'd + substitute; T-8.1 bakes them in, so this is a straight read + + `put_user_policy`.) + - `put_user_policy(UserName=USER_NAME, PolicyName=POLICY_NAME, + PolicyDocument=json.dumps(policy_doc))`. + - Idempotent: `put_user_policy` overwrites if the policy already + exists (this is fine — it keeps the attached policy in sync with + the file). + 7. **`create_access_key()`** — + - List the user's access keys: `list_access_keys(UserName=USER_NAME)`. + - If ≥1 `Active` key exists: log that a key exists, print its + `AccessKeyId` (NOT the secret — the secret is only available at + creation time), and do NOT create a new one (the orchestrator + should run `rotate_spike_key.sh` to rotate). Print a message: + "Active key exists; run scripts/rotate_spike_key.sh to + rotate." Exit 0. + - If 0 active keys: `create_access_key(UserName=USER_NAME)` → + returns `{AccessKeyId, SecretAccessKey, ...}`. Print: + ``` + ACDL_AWS_ACCESS_KEY_ID= + ACDL_AWS_SECRET_ACCESS_KEY= + ``` + to stdout (the orchestrator captures these — they are NEVER + committed, NEVER written to a tracked file). The script does NOT + write them to `.env.secrets` — that is `rotate_spike_key.sh`'s job + (T-8.5); this script's job is the *initial* key creation. (If the + orchestrator prefers, this script can optionally write to + `.env.secrets` too — but the canonical path is to run + `rotate_spike_key.sh` immediately after, which creates a fresh + key + writes `.env.secrets`. Document this in the README.) + 8. **`main()`** — call `create_user()`, `attach_inline_policy()`, + `create_access_key()`. Print a summary. Exit 0 on success. + 9. **No secrets to a tracked file** — the key is printed to stdout + only. `.env.secrets` is written by `rotate_spike_key.sh`, not here. + 10. **Idempotency** — re-running the script: user exists → skip; policy + exists → `put_user_policy` overwrites (idempotent); key exists → + report + do not create (the orchestrator rotates via + `rotate_spike_key.sh` if needed). - **Source citations:** - - `docs/architecture.md` §10 (HITL mechanics — §10.1 gate model, - §10.2 routing, §10.3 SoD, §10.4 full matrix, §10.5 timeout, §10.6 - rejection). - - `ARCHITECTURE.md` §10 (ACDL-repo operating copy — same content, - refined). - - `RESEARCH.md` TARGET 8 (HITL matrix + SoD — Gitea adaptation, - design doc sketch). - - `PROJECT.md` D-042 (approver identity = gitea.actor). + - `PROJECT.md` D-039 (per-run-rotated long-lived key waiver; OIDC + deferred to v1.2 — the user + key are deleted in v1.2). + - `PROJECT.md` D-034 (one-shot bootstrap waiver — the root key is used + once to create this user; the user rotates the root key manually + after Phase 08). + - `terraform/bootstrap/spike_runner_policy.json` T-8.1 (the policy + document this script attaches). - **Commit message:** - `phase: 7, status: plan-as-execute, persona: security-engineer, task: T-7.7` - (then `docs(P07): author platform/hitl_matrix_design.md (REQ-21; D-042 gitea.actor)`) + `phase: 8, status: plan-as-execute, persona: platform-engineer, task: T-8.4` + (then `feat(P08): author create_iam_user.py (REQ-23b; reads T-8.1 policy; D-039)`) - **Self-verify:** - - [ ] File exists, non-empty markdown. - - [ ] All 8 concerns from §10.4 appear (functional, performance, - security, NFRs, op-readiness, incident response, capacity/cost, - resilience). - - [ ] "gitea.actor" appears (D-042). - - [ ] "workflow_dispatch" + "approve_qa" + "approve_prod" + "approve_dr" - appear. - - [ ] "1 business day" + "2 business days" + "PENDING_ATTESTATION" - appear. - - [ ] "supersedes" + "HELD" + "PROMOTION_REJECTED" appear. - - [ ] "CODEOWNERS" appears. - - [ ] "no Environments API" / "no Environments API" appears. - -#### T-7.8 — Author `platform/separation_of_duties.py` - -- **Owner:** security-engineer (territory: - `platform/separation_of_duties.py`). -- **Files owned:** `platform/separation_of_duties.py` (new). -- **What the file must contain** (cite sources): - 1. **Module docstring** — "Check that qaApprover != prodApprover for a - contract (ARCHITECTURE.md §10.3, D-042). Reads `approver_qa` from - the DynamoDB outbox for the contractId, compares to the - prod-dispatch `gitea.actor`. Blocks on equality, emits - `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt artifact to SRE - on-call. Spike: dev-only — the check is a no-op stub that returns - (True, 'dev-only') because the spike does not exercise HITL - (A-8.1)." - 2. **`check(outbox_client, contract_id, current_prod_approver)`** - function: - - `item = outbox_client.get(contract_id)` — reads the outbox item - for the contractId (the outbox shape is defined in - `platform/audit_ledger_design.md` T-7.6: PK `contractId`, SK - `eventType#eventTs`, attributes include `approver_qa`). - - If `item is None`: return `(True, "no prior approver (first - promotion)")` — dev→qa has no SoD check (the first promotion has - no prior approver to compare against). - - `qa_approver = item.get("approver_qa")`. If `not qa_approver`: - return `(True, "no QA approver recorded (dev-only spike)")` — - the spike's dev-only path. - - If `qa_approver == current_prod_approver`: return - `(False, f"SEPARATION_OF_DUTIES_VIOLATION: qaApprover==prodApprover=={qa_approver}")` - — the platform blocks prod promotion, writes a - `SEPARATION_OF_DUTIES_VIOLATION` event to the evidence stream - (the pipeline step does the write; this function returns the - verdict), and routes a halt artifact to SRE on-call. - - Else: return `(True, "distinct")`. - 3. **`route_halt_artifact(contract_id, violation_reason, oncall_client)`** - — a helper that routes the halt artifact to SRE on-call (spike: - stub that logs the artifact; v1.2 wires a real pager). Not invoked - in the dev-only spike. - 4. **Spike-scope note** — a module-level comment: "Spike scope - (A-8.1): the spike is dev-only (REQ-27 contract has - environment: dev); HITL is not exercised. This module is authored - to its full v1.2 shape but the spike calls it with - current_prod_approver=None and a None outbox_client — the check - returns (True, 'dev-only')." - 5. **No external deps** — stdlib only (`typing` for the signature). - `outbox_client` is a duck-typed boto3-like client (has `.get(pk)`); - the spike passes a stub. No `import boto3` (the pipeline step owns - the boto3 client; this function receives it). -- **Source citations:** - - `ARCHITECTURE.md` §10.3 (separation of duties — identity - distinctness sequence). - - `RESEARCH.md` TARGET 8 (SoD sketch — the executor uses this as the - basis). - - `PROJECT.md` D-042 (approver identity = gitea.actor; the check reads - approver_qa from the outbox, compares to the prod-dispatch - gitea.actor). - - `platform/audit_ledger_design.md` T-7.6 (the outbox item shape — - PK, SK, `approver_qa` attribute). -- **Commit message:** - `phase: 7, status: plan-as-execute, persona: security-engineer, task: T-7.8` - (then `feat(P07): author platform/separation_of_duties.py (REQ-21; D-042; reads outbox approver_qa)`) -- **Self-verify:** - - [ ] `python3 -m py_compile platform/separation_of_duties.py` exits 0. - - [ ] `check(None, "any", None)` returns `(True, "no prior approver...")`. - - [ ] `check(stub_with_approver_qa("alice"), "cid", "alice")` returns - `(False, "...SEPARATION_OF_DUTIES_VIOLATION...")`. - - [ ] `check(stub_with_approver_qa("alice"), "cid", "bob")` returns - `(True, "distinct")`. - - [ ] No `import boto3` (duck-typed client). - - [ ] "SEPARATION_OF_DUTIES_VIOLATION" string is present. + - [ ] `python3 -m py_compile terraform/bootstrap/create_iam_user.py` + exits 0. + - [ ] The script reads `spike_runner_policy.json` (relative to its own + `__file__` dir, not CWD — robust to being run from anywhere). + - [ ] `create_access_key` prints `ACDL_AWS_ACCESS_KEY_ID=` + + `ACDL_AWS_SECRET_ACCESS_KEY=` (the orchestrator's capture format). + - [ ] If an active key exists, the script does NOT create a second one + (idempotent — avoids key proliferation). + - [ ] The script does NOT write the key to any file (only stdout). --- -### Wave 4 — backend-engineer + security-engineer co-authored: REQ-19 confidence_signal.py +### Wave 3 — platform-engineer: D-039 key rotation script -One file, one commit, co-authored (PERSONAS.md conflict resolution: -backend owns the 6-input weighted sum + per-env thresholds; security -owns the severity→penalty + critical-override). Depends on T-7.4 -(PolicyCheckResult schema — the confidence signal consumes a list of -PolicyCheckResult records as its `policy` input). +The rotation script is authored (not executed) in Wave 3. It references +the `acdl-spike-runner` user + the scoped policy that Wave 2 creates; +the *contract* depends on Wave 2, but the script runs only in Wave 5. -#### T-7.9 — Author `platform/confidence_signal.py` +#### T-8.5 — Author `scripts/rotate_spike_key.sh` -- **Owners:** backend-engineer (6-input weighted sum + per-env - thresholds + `compute()` orchestration + `Signal` dataclass) + - security-engineer (severity→penalty mapping + critical-override - semantics + the policy-input penalty loop). Co-authored in one commit. - lead-developer arbitrates if the two disagree (PERSONAS.md conflict - resolution). -- **Files owned:** `platform/confidence_signal.py` (new). +- **Owner:** platform-engineer (territory: `scripts/rotate_spike_key.sh` + — the rotation script is infra tooling; lead-developer owns + `scripts/verify_phase*.sh` but not rotation). +- **Files owned:** `scripts/rotate_spike_key.sh` (new). - **What the file must contain** (cite sources): - 1. **Module docstring** — the 6 inputs + weights + severity→penalty, - verbatim from D-040 + ARCHITECTURE.md §8: + 1. **Shebang + strict mode** — `#!/usr/bin/env bash`, + `set -euo pipefail`. + 2. **Header comment** — "Rotate the ACDL spike IAM user access key + (D-039). Uses boto3 with the bootstrap root key from env vars + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID + ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + (set by the orchestrator in the shell, NEVER committed). Creates a + new key for acdl-spike-runner, deactivates + deletes the old, writes + the new key to gitignored .env.secrets (chmod 600). Idempotent: + re-running always ends with exactly 1 active key. Optionally + uploads to Gitea secrets via the Gitea API if ACDL_GITEA_TOKEN is + set; else warns that Gitea secret upload is skipped (v1.2 + hardening). This script does NOT rotate the bootstrap root key + (D-034 closure is a manual user step — see terraform/bootstrap/README.md)." + 3. **Env var validation** — check + `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` + are set; if not, exit with a clear message. Do NOT echo their + values. + 4. **Constants** — `USER_NAME="acdl-spike-runner"`, + `REGION="us-east-1"`, `SECRETS_FILE="$(git rev-parse --show-toplevel)/.env.secrets"`. + 5. **Rotation logic (inline python3 -c with boto3)** — the script uses + `python3 - <<'PYEOF'` (heredoc) to run boto3 inline (the repo has no + python package for this; inline keeps the script self-contained). + The python: + - Build a boto3 `iam` client with the bootstrap creds from env. + - `list_access_keys(UserName=USER_NAME)` → list of access keys. + - If ≥1 `Active` key: pick the first active one as `old_key`; + `create_access_key(UserName=USER_NAME)` → new key; + `update_access_key(AccessKeyId=old_key, Status="Inactive")`; + `delete_access_key(AccessKeyId=old_key)`. Print "Rotated: deleted + , created .". + - If 0 active keys: `create_access_key(UserName=USER_NAME)` → new + key. Print "Created: (no prior active key).". + - The script writes the new key to the `SECRETS_FILE` via the + python (so it can format it): write + `ACDL_AWS_ACCESS_KEY_ID=<...>\nACDL_AWS_SECRET_ACCESS_KEY=<...>\n` + to `SECRETS_FILE`, then `os.chmod(SECRETS_FILE, 0o600)`. + - Print the new `AccessKeyId` (NOT the secret) to stdout for the + log; the secret goes only to `SECRETS_FILE`. + - Exit 0. + 6. **Idempotency** — the logic above always ends with exactly 1 active + key (create new → deactivate old → delete old). Re-running: the + "new" key from the prior run becomes the "old" key, gets + deactivated + deleted, a fresh key is created. The script never + leaves >1 active key. + 7. **Optional Gitea secret upload** — after the python rotation block, + if `ACDL_GITEA_TOKEN` is set + non-empty, upload the new key to + Gitea Actions secrets via the API: + - `PUT ${GITEA_BASE_URL:-https://git.cloudinit.dev}/api/v1/repos/continuous-intelligence/acdl/actions/secrets/ACDL_AWS_ACCESS_KEY_ID` + with body `{"value":""}` + header + `Authorization: token ${ACDL_GITEA_TOKEN}`. + - Same for `ACDL_AWS_SECRET_ACCESS_KEY`. + - Use `curl` (the runner has it) + read the values from + `SECRETS_FILE` (so they don't re-enter the shell env beyond the + python block). + - The plan should verify the Gitea secrets API endpoint shape — the + orchestrator notes "PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{name}" + with body `{value: "..."}`. If the API is unavailable (404 / 403 / + non-200), the script warns: "Gitea secret upload failed (HTTP + ); the key is in .env.secrets only. Gitea secret upload is + a v1.2 hardening step." and continues (exit 0 — the local + `.env.secrets` is sufficient for the spike's local-run path). + - If `ACDL_GITEA_TOKEN` is NOT set, warn: "ACDL_GITEA_TOKEN not + set; Gitea secret upload skipped. The spike key is in + .env.secrets (local-run path). Gitea secret upload is a v1.2 + hardening step." and continue (exit 0). + 8. **No secrets to stdout** — the script prints the new `AccessKeyId` + (AKIA..., non-secret-ish — it's the public half) but NOT the + `SecretAccessKey`. The secret goes only to `SECRETS_FILE`. + 9. **.env.secrets format** — two lines: ``` - Inputs (weights sum to 1.0, D-040): - 1. policy_results (0.30) — list[PolicyCheckResult] (schemas/policy_check_result.schema.json) - 2. validation (0.25) — {schema: bool, ir_resolved: bool, tf_validated: bool, tf_planned: bool} - 3. freshness (0.10) — {age_days: float, max_age_days: float} - 4. source (0.15) — {submitter: str, commit_sha: str, signed: bool} - 5. history (0.10) — {prior_rollbacks: int, prior_policy_fails: int} - 6. nfrs (0.10) — {declared: list[str], conformance: float|None} - Severity → penalty (locked, §8): - critical → hard override (score = 0, block) - high → -0.20 - medium → -0.05 - low → -0.01 - info → 0.00 - Per-env thresholds (locked, §8): dev 0.50, qa 0.75, prod 0.90, dr 0.95. - Output: {score, band, perInput, reasonCodes}. - Halt with explicit reason on missing input (§8). + ACDL_AWS_ACCESS_KEY_ID=<...> + ACDL_AWS_SECRET_ACCESS_KEY=<...> ``` - 2. **`WEIGHTS`** dict — `{"policy":0.30, "validation":0.25, - "freshness":0.10, "source":0.15, "history":0.10, "nfrs":0.10}` - (D-040). Sum = 1.0. - 3. **`PENALTY`** dict — `{"critical":None, "high":0.20, "medium":0.05, - "low":0.01, "info":0.0}` (`None` = hard override — security-engineer - territory). - 4. **`THRESHOLDS`** dict — `{"dev":0.50, "qa":0.75, "prod":0.90, - "dr":0.95}` (ARCHITECTURE.md §8). - 5. **`Signal` dataclass** — `score: float`, `band: Literal["pass", - "warn", "block"]`, `perInput: dict`, `reasonCodes: list[str]`. - 6. **`_per_input_score(name, raw)`** — normalize a raw input to - `[0,1]`. Spike cold-start: unknown/missing inputs → `0.5` (A-6.2) - with a `INPUT_MISSING:` reason code. The per-input scoring - rules (documented inline): - - `policy`: weighted by severity — pass=1.0, skipped=1.0 (treat as - pass — the ACDL_TAG_NAMING skipped record is neutral), fail=0.0 - per finding, averaged. (The severity penalty is applied - separately at the score level — see `compute()`.) - - `validation`: `1.0` if all four bools true, else proportional. - - `freshness`: `1.0 - (age_days / max_age_days)` clamped to `[0,1]`. - - `source`: `1.0` if `submitter` + `commit_sha` present (dev gate is - *presence*, not *identity* — RESEARCH A-6.1). - - `history`: `1.0 - (prior_rollbacks * 0.2 + prior_policy_fails * - 0.1)` clamped to `[0,1]`. Spike cold-start (first submission) = - `0.5` (no history — A-6.2). - - `nfrs`: `1.0` if `conformance` present, else `0.5` (no NFRs - declared — spike cold-start A-6.2). - 7. **`compute(contract_id, environment, inputs)`** — the orchestrator - (backend-engineer): - - If `set(inputs.keys()) != set(WEIGHTS.keys())`: return - `Signal(0.0, "block", {}, [f"INPUT_MISSING:{m}" for m in - missing])` — halt with explicit reason on missing input (§8). - - Build `per_input`, `reasons`, `base` (weighted sum) via - `_per_input_score` for each input. - - **Severity penalty loop (security-engineer):** for each `pcr` in - `inputs.get("policy_results", [])` where `pcr["result"] == "fail"`: - - If `PENALTY[pcr["severity"]] is None` (critical): return - `Signal(0.0, "block", per_input, reasons + - [f"CRITICAL_OVERRIDE:{pcr['ruleId']}"])` — **hard override**, - one critical finding blocks regardless of all other inputs - (§8). - - Else: `penalty += PENALTY[pcr["severity"]]`. - - `score = max(0.0, min(1.0, base - penalty))` (clamp to `[0,1]`). - - `threshold = THRESHOLDS[environment]`. - - `band = "pass" if score >= threshold else ("block" if score < - threshold - 0.10 else "warn")` (the warn band is 0.10 wide — - A-6.3). - - **Dev override:** `if environment == "dev" and band == "warn": - band = "block"` — dev is autonomous, no reviewer to handle a warn - (A-6.3). - - Return `Signal(score, band, per_input, reasons)`. - 8. **`__main__`** — a tiny CLI for spike invocation: read a JSON file - of inputs + environment, print the Signal as JSON. Spike workflow - calls `python3 platform/confidence_signal.py dev`. - 9. **No external deps** — stdlib only (`dataclasses`, `typing`, - `json`, `sys`). The PolicyCheckResult records arrive as plain dicts - (the Checkov adapter emits dicts, not a typed class). - 10. **Spike cold-start note** — a module-level comment: "Spike cold- - start (A-6.2): inputs 3 (freshness), 5 (history), 6 (nfrs) are - 'present + neutral 0.5' because the spike is the first submission - with no history and no declared NFRs. The gate is *presence*, not - *conformance* — the 'all six inputs present' dev gate (§5) is - satisfied by non-null per-input scores." + The verify script (T-8.6) + the spike workflows (Phases 09-10) + `source` this file or parse it. chmod 600 (owner read/write only). + 10. **The bootstrap root key is NOT rotated by this script** — add a + comment + a stdout line at the end: "NOTE: the bootstrap root key + (ACDL_BOOTSTRAP_AWS_*) is NOT rotated by this script. After Phase + 08, rotate/deactivate the root key in the AWS IAM console (D-034 + closure — see terraform/bootstrap/README.md)." - **Source citations:** - - `ARCHITECTURE.md` §8 (confidence signal — 6 inputs, weighted sum, - per-env thresholds, severity→penalty, output shape, halt-on-missing). - - `RESEARCH.md` TARGET 6 (6-input confidence signal sketch — the - executor uses this as the basis; must be self-contained + match - ARCHITECTURE.md). - - `PROJECT.md` D-040 (the 6 inputs + weights frozen for v1). - - `schemas/policy_check_result.schema.json` T-7.4 (the `policy_results` - input is a list of these records). + - `PROJECT.md` D-039 (per-run-rotated long-lived key waiver). + - `PROJECT.md` D-034 (one-shot bootstrap waiver — root key rotated + manually by the user, not by this script). + - `ARCHITECTURE.md` §12.5 + the Gitea API surface table (Gitea secrets + API — the spike optionally uploads; v1.2 uses OIDC, no secret). + - `config.json` gitea block (base_url, owner, repo, api_token_env). - **Commit message:** - `phase: 7, status: plan-as-execute, persona: backend-engineer+security-engineer, task: T-7.9` - (then `feat(P07): author platform/confidence_signal.py (REQ-19; D-040 6 inputs; co-authored backend+security)`) + `phase: 8, status: plan-as-execute, persona: platform-engineer, task: T-8.5` + (then `feat(P08): author rotate_spike_key.sh (D-039; idempotent; optional Gitea secret upload)`) - **Self-verify:** - - [ ] `python3 -m py_compile platform/confidence_signal.py` exits 0. - - [ ] `WEIGHTS` sums to 1.0; values match D-040 exactly. - - [ ] `PENALTY["critical"] is None` (hard override sentinel). - - [ ] `THRESHOLDS` matches dev 0.50 / qa 0.75 / prod 0.90 / dr 0.95. - - [ ] A missing-input call returns `Signal(0.0, "block", ..., - ["INPUT_MISSING:..."])`. - - [ ] A critical-fail PolicyCheckResult hard-overrides to `0.0, block` - regardless of other inputs. - - [ ] A dev `warn` band is flipped to `block` (autonomous, no - reviewer). - - [ ] A cold-start spike input set (all 6 present, policy_results = - [the ACDL_TAG_NAMING skipped record], validation all true, - freshness/source/history/nfrs neutral 0.5) produces a `pass` - for dev (score ≥ 0.50). - - [ ] No non-stdlib imports. + - [ ] `bash -n scripts/rotate_spike_key.sh` exits 0. + - [ ] `set -euo pipefail` is present. + - [ ] The script validates `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` are set before proceeding. + - [ ] The rotation logic uses `python3 - <<'PYEOF'` (or `python3 -c`) + with boto3; no `aws` CLI dependency. + - [ ] The script writes `.env.secrets` + `chmod 600`. + - [ ] The script does NOT echo `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` + or the new `SecretAccessKey` to stdout. + - [ ] The Gitea upload block is guarded by `if [ -n "${ACDL_GITEA_TOKEN:-}" ]` + + degrades gracefully (warns + continues) on API failure. + - [ ] The script prints the D-034 manual-step note at the end. --- -### Wave 5 — lead-developer: verify_phase07.sh + traceability +### Wave 4 — lead-developer: verify script + README + .gitignore -The phase gate. Depends on all prior waves (the verify script asserts -all 6 files exist + validate). Updates the traceability tables in -REQUIREMENTS.md + ROADMAP.md. +Wave 4 asserts everything prior + authors the runbook. It depends on all +prior waves (the verify script asserts the bucket, table, user, policy, +and rotated key all exist + the caller is the user). -#### T-7.10 — Author `scripts/verify_phase07.sh` + traceability update +#### T-8.6 — Author `scripts/verify_phase08.sh` -- **Owner:** lead-developer (territory: `scripts/verify_phase*.sh`, - `.ciagent/**` traceability). -- **Files owned:** `scripts/verify_phase07.sh` (new) + - `.ciagent/REQUIREMENTS.md` (traceability table edit) + - `.ciagent/ROADMAP.md` (Phase 07 status edit). -- **What the verify script must assert** (cite the must_haves in this - PLAN.md frontmatter): - 1. **All 6 schema/design files exist:** - - `schemas/ir.schema.json` - - `schemas/policy_check_result.schema.json` - - `schemas/contract.schema.json` - - `platform/confidence_signal.py` - - `platform/audit_ledger_design.md` - - `platform/hitl_matrix_design.md` - - `adapters/terraform/policy/checkov_adapter.py` - - `platform/separation_of_duties.py` - - `docs/architecture-v1.0.md` - (9 files — the 6 REQ-mapped files + checkov_adapter + SoD + the - snapshot; count carefully: REQ-17→1, REQ-18→2, REQ-19→1, REQ-20→1, - REQ-21→2, REQ-22→1, REQ-16→1 = 9 files total.) - 2. **The 3 JSON Schemas validate** as JSON Schema draft 2020-12: - ```bash - python3 -m jsonschema schemas/ir.schema.json && \ - python3 -m jsonschema schemas/policy_check_result.schema.json && \ - python3 -m jsonschema schemas/contract.schema.json - ``` - (If `python3 -m jsonschema` is unavailable, fall back to `ajv - compile -s schemas/` — note this in the script.) - 3. **The 3 .py files py_compile:** - ```bash - python3 -m py_compile platform/confidence_signal.py \ - platform/separation_of_duties.py \ - adapters/terraform/policy/checkov_adapter.py - ``` - 4. **The 3 .md design files are non-empty** (markdown presence): - ```bash - test -s platform/audit_ledger_design.md && \ - test -s platform/hitl_matrix_design.md && \ - test -s docs/architecture-v1.0.md - ``` - 5. **All 11 decision IDs + OpenTofu are present in PROJECT.md:** - ```bash - for id in W1.A W1.B W2.A W3.D W3.E BA.A BA.B BA.C BA.D BA.E BA.F; do - grep -q "$id" .ciagent/PROJECT.md || FAIL "missing $id" - done - grep -qi "opentofu" .ciagent/PROJECT.md || FAIL "missing OpenTofu" - ``` - 6. **`docs/architecture-v1.0.md` exists + status is v1.0:** - ```bash - test -f docs/architecture-v1.0.md - grep -q "v1.0" docs/architecture-v1.0.md | head -1 # status line - ``` - 7. **D-040, D-041, D-042, D-043, D-044 present in PROJECT.md** (the - Phase 07-relevant decisions): - ```bash - for d in D-040 D-041 D-042 D-043 D-044; do - grep -q "$d" .ciagent/PROJECT.md || FAIL "missing $d" - done - ``` - 8. **The spike contract validates against the contract schema** (a - cross-file consistency check — the spike contract is the one - REQ-27 will use): - ```bash - # Write a minimal dev contract to a temp file, validate against - # schemas/contract.schema.json. (The real contracts/spike.yaml - # lands in Phase 10.) - echo '{"stack":"l2-static-asset","environment":"dev","inputs":{"bucket_name":"x","region":"us-east-1"}}' \ - > /tmp/spike-contract.json - python3 -c "import jsonschema, json; \ - jsonschema.validate(json.load(open('/tmp/spike-contract.json')), \ - json.load(open('schemas/contract.schema.json')))" - ``` - 9. **A minimal IR instance validates against the IR schema:** - ```bash - echo '{"version":"1.0.0","stack":{"name":"l2-static-asset","kind":"l2","depth":1},"resources":[{"id":"s3","type":"aws:s3:bucket","module":"l1-s3@1.0.0","inputs":{"bucket_name":"x","region":"us-east-1"}}]}' \ - > /tmp/spike-ir.json - python3 -c "import jsonschema, json; \ - jsonschema.validate(json.load(open('/tmp/spike-ir.json')), \ - json.load(open('schemas/ir.schema.json')))" - ``` - 10. **Exit 0 on success, non-zero on any failure** (with a clear - `FAIL: ` message per assertion). Print `VERIFIED — Phase - 07: architecture v1.0 finalized; 6 files authored + 11 decisions - resolved` on success. -- **Traceability update** (in the same commit or a follow-up): - - `.ciagent/REQUIREMENTS.md` traceability table: REQ-16..22 → - `complete (v1.1.2)` (the Phase 07 ship tag — Phase 06 was v1.1.1). - - `.ciagent/ROADMAP.md` Phase 07 status: `pending` → `complete - (v1.1.2)`. +- **Owner:** lead-developer (territory: `scripts/verify_phase*.sh` per + PERSONAS.md). +- **Files owned:** `scripts/verify_phase08.sh` (new). +- **What the file must contain** (cite sources): + 1. **Shebang + strict mode** — `#!/usr/bin/env bash`, + `set -euo pipefail`. + 2. **Header comment** — "Verify Phase 08 AWS bootstrap (REQ-23). + Loads the rotated spike key from .env.secrets, runs + sts.get_caller_identity() via boto3 with those creds, asserts the + caller is acdl-spike-runner (NOT root). Asserts the S3 state bucket + exists, the DynamoDB outbox table exists, the IAM user exists with + the scoped inline policy containing the Deny statement. Asserts + .env.secrets + terraform/bootstrap/.bootstrap_state.json are + gitignored. Prints VERIFIED on success." + 3. **Constants** — `REGION="us-east-1"`, + `ACCOUNT_ID="581513795199"`, + `BUCKET_NAME="acdl-tfstate-581513795199-us-east-1"`, + `TABLE_NAME="acdl-outbox"`, `USER_NAME="acdl-spike-runner"`, + `SECRETS_FILE="$(git rev-parse --show-toplevel)/.env.secrets"`, + `MARKER_FILE="$(git rev-parse --show-toplevel)/terraform/bootstrap/.bootstrap_state.json"`. + 4. **Pre-flight checks** — + - `.env.secrets` exists; if not, exit "FAIL: .env.secrets missing + — run scripts/rotate_spike_key.sh first". + - `.env.secrets` is gitignored: `git check-ignore -q "$SECRETS_FILE"` + → if it fails, exit "FAIL: .env.secrets is NOT gitignored + (security violation)". + - `.bootstrap_state.json` exists + is gitignored (same check). + 5. **Load the rotated key** — parse `.env.secrets` into env vars + (`set -a; . "$SECRETS_FILE"; set +a` or `export $(grep -v '^#' + "$SECRETS_FILE" | xargs)`). Do NOT echo them. + 6. **Assert caller identity (the rotated spike key works + is the + user, NOT root)** — `python3 - <<'PYEOF'` with boto3 using + `ACDL_AWS_ACCESS_KEY_ID` + `ACDL_AWS_SECRET_ACCESS_KEY` from the + loaded env: + - `sts.get_caller_identity()` → `Arn`. + - Assert `Arn == "arn:aws:iam::581513795199:user/acdl-spike-runner"` + (NOT `arn:aws:iam::581513795199:root`). If it's root, exit "FAIL: + caller is root — the spike key was not rotated; D-034 not closed". + - Print "caller-identity: ". + 7. **Assert S3 bucket exists** — `python3 - <<'PYEOF'` with boto3: + `s3.head_bucket(Bucket=BUCKET_NAME)`; if it raises, exit "FAIL: S3 + state bucket missing". Print "s3-bucket: OK". + 8. **Assert DynamoDB table exists** — `python3 - <<'PYEOF'` with boto3: + `dynamodb.describe_table(TableName=TABLE_NAME)`; assert + `BillingModeSummary.BillingMode == "PAY_PER_REQUEST"`, the key + schema is PK `contractId` (HASH) + SK `eventType#eventTs` (RANGE), + TTL is enabled on `expire_at`. Print "dynamodb-table: OK + (PAY_PER_REQUEST, PK contractId, SK eventType#eventTs, TTL + expire_at)". + 9. **Assert IAM user exists with the scoped policy** — + `python3 - <<'PYEOF'` with boto3 using the **bootstrap** key (the + spike user cannot read its own policy via `get_user_policy`? It can + — `iam:GetUserPolicy` on its own user; but to be safe, use the + bootstrap key for this assertion — it's the last verify step, then + the bootstrap key is done): + - `iam.get_user(UserName=USER_NAME)` → exists. + - `iam.get_user_policy(UserName=USER_NAME, PolicyName="acdl-spike-runner-policy")` + → `PolicyDocument` (URL-encoded; `urllib.parse.unquote` it, then + `json.loads`). + - Assert the policy has 4 statements: S3 Allow, DynamoDB Allow, + STS Allow, Deny. Assert the Deny statement has `Action: "*"` + + `NotResource` with the S3 + DynamoDB ARNs (least privilege — T-8.1 + security contract). If the Deny is missing, exit "FAIL: IAM + policy missing least-privilege Deny statement". + - Assert no Allow statement grants `terraform` or `iam:` or `ec2:` + actions (the user cannot create/modify infra). + - Print "iam-user: OK (acdl-spike-runner, scoped policy with Deny)". + 10. **Assert .bootstrap_state.json contents** — `python3 -c` reads it, + asserts `account_id == "581513795199"`, `bucket_name` matches, etc. + 11. **Success message** — print: + ``` + VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now) + ``` + Exit 0. + 12. **Failure mode** — any assertion failure: print `FAIL: ` + to stderr + exit 1. Do NOT print any secret in any error message. - **Source citations:** - - This PLAN.md's must_haves (the assertions enumerate them). - - `PERSONAS.md` verification_toolchain (typecheck/test/build - substitutes — `python3 -m jsonschema`, `py_compile`, - `scripts/verify_phaseNN.sh`). + - `ROADMAP.md` Phase 08 success criteria (S3 bucket + DynamoDB table + exist; IAM user with scoped policy; rotated key; + `sts get-caller-identity` succeeds; D-034 closed). + - `terraform/bootstrap/spike_runner_policy.json` T-8.1 (the policy the + verify script asserts). + - `terraform/bootstrap/create_state_backend.py` T-8.3 (the + `.bootstrap_state.json` shape). - **Commit message:** - `phase: 7, status: plan-as-execute, persona: lead-developer, task: T-7.10` - (then `verify(P07): VERIFIED — 6 files authored, 11 decisions resolved, architecture v1.0`) - (then `docs(P07): post-ship traceability + roadmap update (v1.1.2)`) + `phase: 8, status: plan-as-execute, persona: lead-developer, task: T-8.6` + (then `feat(P08): author verify_phase08.sh (asserts bucket+table+user+policy+rotated-key)`) - **Self-verify:** - - [ ] `scripts/verify_phase07.sh` exits 0 when run from repo root. - - [ ] The script fails (non-zero) if any of the 9 files is missing. - - [ ] The script fails if any of the 3 JSON Schemas is invalid JSON - Schema. - - [ ] The script fails if any of the 3 .py files fails py_compile. - - [ ] The script fails if any of the 11 decision IDs is missing from - PROJECT.md. - - [ ] The script fails if `docs/architecture-v1.0.md` does not say - `v1.0` in its status line. - - [ ] REQUIREMENTS.md traceability shows REQ-16..22 complete. - - [ ] ROADMAP.md Phase 07 status is complete. + - [ ] `bash -n scripts/verify_phase08.sh` exits 0. + - [ ] `set -euo pipefail` is present. + - [ ] The script checks `.env.secrets` + `.bootstrap_state.json` are + gitignored BEFORE loading them (security). + - [ ] The caller-identity assertion checks for the user ARN, NOT root. + - [ ] The IAM policy assertion checks for the Deny statement. + - [ ] The success message includes "D-034 closed (user must rotate the + root key manually now)". + - [ ] No secret is echoed in any error path. + +#### T-8.7 — Author `terraform/bootstrap/README.md` + update `.gitignore` + +- **Owner:** lead-developer (territory: `docs/**` meta + `.gitignore` + + `README.md` per PERSONAS.md). +- **Files owned:** `terraform/bootstrap/README.md` (new); + `.gitignore` (edit — append two lines). +- **What `terraform/bootstrap/README.md` must contain** (cite sources): + 1. **Title + purpose** — "ACDL Phase 08 — AWS Bootstrap Runbook + (REQ-23). This directory bootstraps the S3 state bucket, DynamoDB + outbox table, and the minimal IAM user for the v1.1 spike. Real + OIDC is deferred to v1.2 (D-039, blocked on go-gitea/gitea#36988); + the spike uses a per-run-rotated long-lived key (D-039 waiver)." + 2. **Prerequisites** — + - The bootstrap root key for account `581513795199` (the user + pastes it; it is the root account credential — high privilege). + - `boto3` installed (`pip install boto3` — 1.43.7 verified working + from the repo root after the `platform/` → `acdl_platform/` + rename). + - The env vars `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` set in the shell to the + root key. **NEVER commit these. NEVER echo them.** + - Optional: `ACDL_GITEA_TOKEN` set if you want `rotate_spike_key.sh` + to upload the rotated key to Gitea Actions secrets (else it + writes to `.env.secrets` only — sufficient for the spike's + local-run path). + 3. **Run order** — numbered steps: + 1. Set the bootstrap env vars in your shell: + ```bash + export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID='' + export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY='' + ``` + 2. Create the state backend: + ```bash + python3 terraform/bootstrap/create_state_backend.py + ``` + 3. Create the IAM user + initial key (capture stdout): + ```bash + python3 terraform/bootstrap/create_iam_user.py | tee /tmp/initial_key.txt + # /tmp/initial_key.txt is OUTSIDE the repo — never commit it. + # Or: capture the ACDL_AWS_ACCESS_KEY_ID / ACDL_AWS_SECRET_ACCESS_KEY + # lines from stdout and set them as env vars for the next step. + ``` + 4. Rotate the key (creates a fresh key, deactivates the initial + one, writes `.env.secrets`): + ```bash + bash scripts/rotate_spike_key.sh + ``` + 5. Verify: + ```bash + bash scripts/verify_phase08.sh + ``` + 6. **MANUAL — D-034 closure:** rotate/deactivate the **root key** + in the AWS IAM console + (https://console.aws.amazon.com/iam/ → Users → root → Security + credentials → "Make inactive" or delete the key). **The user + does this, not the script.** The root key was the one-shot + bootstrap credential (D-034); after Phase 08 it must not remain + active. Record the closure in PROJECT.md (Wave 5 does this). + 4. **Spike scope vs v1.2 boundary** — the table (see §"Spike scope vs + v1.2 boundary" below; copy it into the README). + 5. **Files in this directory** — a table: + - `create_state_backend.py` — S3 + DynamoDB creation (T-8.3). + - `create_iam_user.py` — IAM user + policy + initial key (T-8.4). + - `spike_runner_policy.json` — the scoped IAM policy (T-8.1, the + security contract). + - `README.md` — this runbook. + - `.bootstrap_state.json` — bookkeeping marker (gitignored; written + by `create_state_backend.py`). + 6. **Security constraints** — the binding constraints (see §"Security + constraints" below; copy them into the README). + 7. **Decision trail** — cite D-034, D-039, D-044, D-P08-1, D-P08-2. +- **What `.gitignore` must add** (append, do not duplicate if present): + ``` + # Phase 08 — AWS bootstrap secrets + bookkeeping (never commit) + .env.secrets + terraform/bootstrap/.bootstrap_state.json + ``` + Check first with `grep` that neither line exists; append only if + missing. Do not remove existing entries. +- **Source citations:** + - `ROADMAP.md` Phase 08 success criteria + D-034 closure step. + - `PROJECT.md` D-034, D-039, D-044. + - `RESEARCH.md` TARGET 1 (OIDC not supported; D-039 waiver). +- **Commit message:** + `phase: 8, status: plan-as-execute, persona: lead-developer, task: T-8.7` + (then `docs(P08): author terraform/bootstrap/README.md + .gitignore (.env.secrets, .bootstrap_state.json)`) +- **Self-verify:** + - [ ] `terraform/bootstrap/README.md` exists, non-empty. + - [ ] All 6 run-order steps appear (including the MANUAL D-034 step). + - [ ] The spike-scope vs v1.2 table is present. + - [ ] The security constraints section is present. + - [ ] `.gitignore` contains `.env.secrets` + + `terraform/bootstrap/.bootstrap_state.json` (grep both). + - [ ] `git check-ignore .env.secrets` exits 0 (gitignored). + - [ ] `git check-ignore terraform/bootstrap/.bootstrap_state.json` + exits 0 (gitignored). + +--- + +### Wave 5 — lead-developer (EXECUTE-only): run the bootstrap against real AWS + +**This wave is the only wave that makes AWS API calls.** It runs the +authored files in order, then closes D-034. It does NOT author any files +— it executes Waves 1-4's deliverables. The root key is used in this +wave only; after Wave 5 the root key is rotated by the user (D-034 +closure). + +#### T-8.8 — Execute the bootstrap + close D-034 + +- **Owner:** lead-developer (orchestrates; the execution touches + platform-engineer's scripts + security-engineer's policy). +- **Files owned:** none authored (this wave runs files from prior + waves); the only file mutation is the traceability update + (`REQUIREMENTS.md`, `ROADMAP.md`, `PROJECT.md` — lead-developer + territory). +- **Execution sequence** (the orchestrator runs these in a shell with + `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` + set to the root key; the key is NOT committed, NOT echoed): + 1. `python3 terraform/bootstrap/create_state_backend.py` — creates the + S3 bucket + DynamoDB table. Assert exit 0. Assert + `terraform/bootstrap/.bootstrap_state.json` was written. + 2. `python3 terraform/bootstrap/create_iam_user.py` — creates the IAM + user + attaches the policy + creates the initial key (prints + `ACDL_AWS_ACCESS_KEY_ID=...` + `ACDL_AWS_SECRET_ACCESS_KEY=...` to + stdout). Capture stdout to a temp file OUTSIDE the repo + (`/tmp/initial_key.txt`); the initial key is a temporary key that + `rotate_spike_key.sh` immediately rotates + deletes. + 3. `bash scripts/rotate_spike_key.sh` — creates a fresh key for + `acdl-spike-runner`, deactivates + deletes the initial key from + step 2, writes `.env.secrets` (chmod 600). Optionally uploads to + Gitea secrets if `ACDL_GITEA_TOKEN` is set. Assert exit 0. Assert + `.env.secrets` exists + is chmod 600 + is gitignored. + 4. `bash scripts/verify_phase08.sh` — asserts the caller is + `acdl-spike-runner` (NOT root), the bucket + table + user + policy + exist, `.env.secrets` + `.bootstrap_state.json` are gitignored. + Assert exit 0 + the "VERIFIED — Phase 08..." message. + 5. **D-034 closure (MANUAL — the user does this):** instruct the user + to rotate/deactivate the root key in the AWS IAM console. This is + NOT a script step — the plan + README call this out. After the user + confirms, record in `PROJECT.md` (decision D-034 row → "Status: + closed — root key rotated by the user after Phase 08 + ()"). + 6. **Traceability update** (lead-developer): + - `REQUIREMENTS.md` traceability table: `REQ-23 | 08 | complete (v1.1.3)`. + - `ROADMAP.md` Phase 08 row: `Status: complete (v1.1.3)`. + - `PROJECT.md` D-034 row: add a closure note. +- **Commit message:** + `phase: 8, status: execute, persona: lead-developer, task: T-8.8` + (then `ship: phase-08 aws-bootstrap (v1.1.3)` + a separate + `docs(P08): post-ship traceability + D-034 closure (v1.1.3)`). +- **Self-verify (the verify script in step 4 is the gate):** + - [ ] Step 1 exits 0 + `.bootstrap_state.json` exists. + - [ ] Step 2 exits 0 + stdout has the two `ACDL_AWS_*` lines. + - [ ] Step 3 exits 0 + `.env.secrets` exists + chmod 600 + gitignored. + - [ ] Step 4 exits 0 + prints the VERIFIED line (with the D-034 note). + - [ ] The user confirms the root key is rotated in the AWS console. + - [ ] `REQUIREMENTS.md` + `ROADMAP.md` + `PROJECT.md` updated. --- ## Wave ordering + dependencies (summary) ``` -Wave 1 (lead-developer) T-7.1 architecture-v1.0.md (umbrella; references decisions) - ↓ -Wave 2 (platform + backend) T-7.2 ir.schema.json (platform-engineer) - T-7.3 contract.schema.json (backend-engineer) [parallel with T-7.2] - ↓ -Wave 3 (security-engineer) T-7.4 policy_check_result.schema.json - T-7.5 checkov_adapter.py (depends T-7.4) - T-7.6 audit_ledger_design.md - T-7.7 hitl_matrix_design.md - T-7.8 separation_of_duties.py (depends T-7.6 outbox shape) - ↓ -Wave 4 (backend + security) T-7.9 confidence_signal.py (depends T-7.4 PolicyCheckResult; co-authored) - ↓ -Wave 5 (lead-developer) T-7.10 verify_phase07.sh + traceability (depends all prior) +Wave 1 (security) T-8.1 spike_runner_policy.json + ↓ (policy file is read by) +Wave 2 (platform) T-8.3 create_state_backend.py ┐ parallel + T-8.4 create_iam_user.py ┘ (T-8.4 reads T-8.1) + ↓ (user + bucket/table contract) +Wave 3 (platform) T-8.5 rotate_spike_key.sh (refs Wave 2 user) + ↓ (everything prior exists) +Wave 4 (lead) T-8.6 verify_phase08.sh (asserts all) + T-8.7 README.md + .gitignore (runbook) + ↓ (all files authored + typechecked) +Wave 5 (lead, EXEC) T-8.8 run bootstrap → close D-034 (the only AWS-touching wave) ``` -**Parallelizable within a wave:** -- Wave 2: T-7.2 (IR, platform-engineer) || T-7.3 (contract, - backend-engineer) — independent schemas, different owners, no shared - files. - -**Sequential within a wave (same persona or file dependency):** -- Wave 3: T-7.4 → T-7.5 (adapter consumes the schema it emits) → T-7.6 - → T-7.7 → T-7.8 (SoD reads the outbox shape T-7.6 documents). All - security-engineer; one persona, sequential to avoid territory - friction. - -**Cross-wave dependencies:** -- Wave 4 → Wave 3 T-7.4 (confidence_signal consumes PolicyCheckResult). -- Wave 4 → Wave 3 T-7.6 (confidence_signal's policy input shape is - informed by the outbox/ledger design — not a hard dependency, but - consistency). -- Wave 5 → all prior waves (verify script asserts all files). +- **Wave 2 → Wave 1:** `create_iam_user.py` reads + `spike_runner_policy.json`. +- **Wave 3 → Wave 2:** `rotate_spike_key.sh` references the + `acdl-spike-runner` user + the policy that Wave 2 establishes (the + *contract* depends on Wave 2; the script runs in Wave 5). +- **Wave 4 → all prior:** `verify_phase08.sh` asserts the bucket, + table, user, policy, and rotated key all exist. +- **Wave 5 → Wave 4:** EXECUTE-only; runs the authored files in order. + Wave 5 is the only wave that touches real AWS. --- -## Per-file content checklist (executor self-verify) +## Explicit per-file content checklist (consolidated) -### `schemas/ir.schema.json` (REQ-17, T-7.2) -- [ ] JSON Schema draft 2020-12 (`$schema` + `$id` + `title`). -- [ ] Top-level `required: [version, stack, resources]`. -- [ ] `stack.depth` integer min 1 max 5. -- [ ] `stack.name` pattern `^l[12]-[a-z][a-z0-9-]*$`. -- [ ] `stack.kind` enum `[l1, l2]`. -- [ ] `resource` required `[id, type, module, inputs]`. -- [ ] `resource.module` pattern `^l1-[a-z][a-z0-9-]*@\d+\.\d+\.\d+$` - (name@semver per W3.D). -- [ ] `resource.parent` (single parent per child, ARCHITECTURE.md §12.1). -- [ ] `resource.outputs` (typed outputSpec). -- [ ] `resource.nfrs` (opaque to adapter, consumed by confidence NFR - input). -- [ ] `relationship.kind` enum `[parent, depends_on, uses_output]`. -- [ ] `relationship.shared_keyword` reserved for v2 (present, unused). -- [ ] **Substrate-agnostic invariant:** no Terraform-block keywords - (`variable`, `output` as block, `aws_` provider prefixes) in the - schema body; `type` values are IR types (`aws:s3:bucket`), not - Terraform types (`aws_s3_bucket`). -- [ ] `$comment` explains the v1 round-trip to Terraform. -- [ ] `python3 -m jsonschema` validates the schema itself. -- [ ] A minimal valid IR instance validates against it. +### `terraform/bootstrap/spike_runner_policy.json` +- [ ] Valid JSON; `Version: "2012-10-17"`. +- [ ] Statement 1 (S3 Allow): `s3:PutObject`/`GetObject`/`DeleteObject`/`ListBucket` + on `arn:aws:s3:::acdl-tfstate-581513795199-us-east-1` + `.../*`. +- [ ] Statement 2 (DynamoDB Allow): `dynamodb:GetItem`/`PutItem`/`DeleteItem`/`UpdateItem` + on `arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox`. +- [ ] Statement 3 (STS Allow): `sts:GetCallerIdentity` on `*`. +- [ ] Statement 4 (Deny): `Action: "*"` + `NotResource` = [S3 + DynamoDB ARNs] + (explicit deny-everything-else — least privilege). +- [ ] NO `terraform`/`iam:`/`ec2:` actions in any Allow statement (no + `terraform apply` — plan-only spike). +- [ ] Account id `581513795199` is concrete in all ARNs. +- [ ] `_doc` top-level key with the spike-scope + v1.2 note. -### `schemas/policy_check_result.schema.json` (REQ-18, T-7.4) -- [ ] JSON Schema draft 2020-12. -- [ ] Canonical shape from ARCHITECTURE.md §12.6: `contractId`, - `evaluatedAt`, `engine`, `ruleId`, `severity`, `result`, - `message`, `evidence`, `resourceRef`. -- [ ] `engine` enum `[checkov, kyverno, opa]`. -- [ ] `severity` enum `[critical, high, medium, low, info]`. -- [ ] `result` enum `[pass, fail, skipped, error]`. -- [ ] `evidence` object, additionalProperties true (opaque to signal). -- [ ] `python3 -m jsonschema` validates; a valid instance passes. +### `terraform/bootstrap/create_state_backend.py` +- [ ] `python3 -m py_compile` exits 0; `boto3` is the only third-party import. +- [ ] Reads `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` from env (raises if missing). +- [ ] `us-east-1` `LocationConstraint` gotcha commented (no + `CreateBucketConfiguration` for us-east-1). +- [ ] S3 bucket `acdl-tfstate-581513795199-us-east-1`: versioning enabled + (`put_bucket_versioning`); public access block (all 4 True); + optional SSE-S3. +- [ ] DynamoDB table `acdl-outbox`: `PAY_PER_REQUEST`, PK `contractId` + (HASH), SK `eventType#eventTs` (RANGE), TTL `expire_at` (epoch + seconds) enabled. +- [ ] Both `create_*` functions skip-if-exists (idempotent). +- [ ] `.bootstrap_state.json` written with `{account_id, bucket_name, + table_name, region, created_at, versioning, ttl_attribute}`. +- [ ] No secrets to stdout (no key echo). -### `adapters/terraform/policy/checkov_adapter.py` (REQ-18, T-7.5) -- [ ] Module docstring (Checkov JSON → PolicyCheckResult). -- [ ] `RULE_MAP` with all 11 Checkov rule IDs (CKV_AWS_41/45/46/20/57/ - 24/25/1/40/7/33). -- [ ] `_to_pcr()` maps severity (prefer Checkov's, fall back to - RULE_MAP default). -- [ ] `_emit_tag_naming_skipped()` emits the ACDL_TAG_NAMING SKIPPED - record (D-043). -- [ ] `adapt()` tolerates both Checkov JSON shapes - (`{framework:{results:{...}}}` and `{framework:{...}}`). -- [ ] `__main__` CLI: `checkov_adapter.py `. -- [ ] stdlib only (no `import boto3` / no third-party). -- [ ] `python3 -m py_compile` exits 0. -- [ ] Output records validate against policy_check_result.schema.json. +### `terraform/bootstrap/create_iam_user.py` +- [ ] `python3 -m py_compile` exits 0; `boto3` is the only third-party import. +- [ ] Reads `spike_runner_policy.json` relative to its own `__file__` dir. +- [ ] `create_user` skip-if-exists; `attach_inline_policy` via + `put_user_policy` (idempotent overwrite). +- [ ] `create_access_key`: if ≥1 active key, report + do not create; if + 0, create + print `ACDL_AWS_ACCESS_KEY_ID=...` + + `ACDL_AWS_SECRET_ACCESS_KEY=...` to stdout. +- [ ] No secrets to a tracked file (only stdout). -### `schemas/contract.schema.json` (REQ-22, T-7.3) -- [ ] JSON Schema draft 2020-12. -- [ ] Top-level `required: [stack, environment]` (dev minimum). -- [ ] `stack` pattern `^l2-[a-z][a-z0-9-]*$`. -- [ ] `environment` enum `[dev, qa, prod, dr]` (no `staging`). -- [ ] `inputs` object, additionalProperties `[string, number, boolean]`, - always optional. -- [ ] `validation` (e2eSuite + loadTest), `runbook`, `dashboard`, - `oncall`, `drDrillRef` all present. -- [ ] `profile` enum `[developer, agentic]`, default `developer`. -- [ ] L3B fields `naturalLanguageIntent`, `confidenceAtSubmission`, - `agentTrace` present, optional everywhere. -- [ ] `supersedes` (uuid, optional). -- [ ] `allOf` conditionals: qa→validation required, prod→runbook+ - dashboard+oncall required, dr→drDrillRef required, - agentic→naturalLanguageIntent required. -- [ ] Spike contract validates; qa/prod/dr/agentic negative cases fail. +### `scripts/rotate_spike_key.sh` +- [ ] `bash -n` exits 0; `set -euo pipefail`. +- [ ] Validates `ACDL_BOOTSTRAP_AWS_*` env vars; does not echo them. +- [ ] Rotation via `python3 - <<'PYEOF'` + boto3 (no `aws` CLI dep): + create new → deactivate old → delete old; or create if 0 keys. +- [ ] Writes `.env.secrets` (chmod 600); does not echo the secret to + stdout. +- [ ] Optional Gitea secret upload (guarded by `ACDL_GITEA_TOKEN`); + degrades gracefully on API failure. +- [ ] Prints the D-034 manual-step note at the end. +- [ ] Idempotent (re-run ends with exactly 1 active key). -### `platform/confidence_signal.py` (REQ-19, T-7.9) -- [ ] Module docstring with the 6 inputs + weights + severity→penalty. -- [ ] `WEIGHTS` = `{policy:0.30, validation:0.25, freshness:0.10, - source:0.15, history:0.10, nfrs:0.10}` (D-040; sums to 1.0). -- [ ] `PENALTY` = `{critical:None, high:0.20, medium:0.05, low:0.01, - info:0.0}` (security-engineer; None = hard override). -- [ ] `THRESHOLDS` = `{dev:0.50, qa:0.75, prod:0.90, dr:0.95}` (§8). -- [ ] `Signal` dataclass: `score, band, perInput, reasonCodes`. -- [ ] `_per_input_score()` per-input rules (policy severity-weighted, - validation all-true, freshness age ratio, source presence, history - rollback/fail, nfrs conformance or neutral 0.5). -- [ ] `compute()` halts with `INPUT_MISSING:` on missing input. -- [ ] `compute()` critical-override: any `fail` + `critical` → - `Signal(0.0, "block", ..., ["CRITICAL_OVERRIDE:"])`. -- [ ] `compute()` dev `warn` flipped to `block` (autonomous, no - reviewer). -- [ ] Spike cold-start (all 6 present, ACDL_TAG_NAMING skipped, - validation true, others neutral 0.5) → dev `pass`. -- [ ] stdlib only; `python3 -m py_compile` exits 0. +### `scripts/verify_phase08.sh` +- [ ] `bash -n` exits 0; `set -euo pipefail`. +- [ ] Pre-flight: `.env.secrets` + `.bootstrap_state.json` exist + are + gitignored (security gate BEFORE loading). +- [ ] Caller-identity assertion: Arn is the user, NOT root. +- [ ] S3 `head_bucket` succeeds; DynamoDB `describe_table` succeeds with + the right schema (PAY_PER_REQUEST, PK/SK, TTL). +- [ ] IAM `get_user` + `get_user_policy` succeed; the Deny statement is + present. +- [ ] Success message includes "D-034 closed (user must rotate the root + key manually now)". +- [ ] No secret in any error path. -### `platform/audit_ledger_design.md` (REQ-20, T-7.6) -- [ ] Header notes Phase 07 design + spike scope (D-041) + v1.2 - build-out. -- [ ] Three tiers: S3 Object Lock (cold/source of truth), acdl-evidence - (hot/query index), DynamoDB outbox (write path). -- [ ] S3 Object Lock: **compliance mode**, **7-year retention**. -- [ ] DynamoDB outbox: RPO=0, `PAY_PER_REQUEST` (D-044), PK/SK, - TTL `expire_at` = now + 365d. -- [ ] `prev_event_hash` chain: SHA-256 over canonical JSON, genesis - `prev_hash="GENESIS"`. -- [ ] JWS: detached signature, platform KMS key, quarterly rotation - (v1.2). -- [ ] Daily checkpoints (v1.2; spike runs in minutes). -- [ ] Async worker + DLQ (v1.2; spike RTO = workflow re-run). -- [ ] Outbox item shape (PK, SK, payload, prev_event_hash, hash, - approver_qa, approver_prod, environment, stack, score, band, - expire_at; v1.2: jws, checkpoint_ref). -- [ ] RPO/RTO table (spike vs v1.2). -- [ ] Decision trail: D-041, D-044, D-042 cited. -- [ ] Clear "Spike scope" vs "v1.2 build-out" section boundary. -- [ ] File non-empty. +### `terraform/bootstrap/README.md` +- [ ] All 6 run-order steps (including the MANUAL D-034 step). +- [ ] Spike-scope vs v1.2 boundary table. +- [ ] Security constraints section. +- [ ] Decision trail (D-034, D-039, D-044, D-P08-1, D-P08-2). -### `platform/hitl_matrix_design.md` (REQ-21, T-7.7) -- [ ] Header notes Phase 07 design + v1.2 wiring + spike dev-only. -- [ ] Pre-execution gate model (§10.1). -- [ ] Gitea-specific gate mechanics: `workflow_dispatch` + - `approve_qa`/`approve_prod`/`approve_dr`, `gitea.actor` as - approver identity (D-042). -- [ ] "no Environments API" note (Gitea constraint). -- [ ] CODEOWNERS routing (§10.2). -- [ ] Full 8-concern matrix from §10.4 (verbatim table). -- [ ] Timeout 1d warn / 2d freeze (§10.5) + `PENDING_ATTESTATION*` - states. -- [ ] Rejection → HELD + `PROMOTION_REJECTED` + `supersedes` (§10.6). -- [ ] SoD section pointing at `platform/separation_of_duties.py`. -- [ ] Spike-scope note (dev-only, HITL not exercised). -- [ ] D-042 cited. -- [ ] File non-empty. - -### `platform/separation_of_duties.py` (REQ-21, T-7.8) -- [ ] Module docstring (qaApprover != prodApprover, D-042, reads outbox). -- [ ] `check(outbox_client, contract_id, current_prod_approver)` → - `(ok: bool, reason: str)`. -- [ ] `None` outbox item → `(True, "no prior approver...")`. -- [ ] Empty `approver_qa` → `(True, "no QA approver... (dev-only)")`. -- [ ] `qa_approver == current_prod_approver` → `(False, - "SEPARATION_OF_DUTIES_VIOLATION: ...")`. -- [ ] Distinct → `(True, "distinct")`. -- [ ] `route_halt_artifact()` helper (spike stub). -- [ ] stdlib only (duck-typed outbox_client; no `import boto3`). -- [ ] `python3 -m py_compile` exits 0. - -### `docs/architecture-v1.0.md` (REQ-16, T-7.1) -- [ ] Status line says `v1.0` (not `v0.2`). -- [ ] Resolution log table with all 11 IDs (W1.A, W1.B, W2.A, W3.D, - W3.E, BA.A, BA.B, BA.C, BA.D, BA.E, BA.F, Q1.3). -- [ ] §13 markers changed from `🟡 OPEN` to `✅ RESOLVED (see - PROJECT.md)`. -- [ ] §14 status rewritten to v1.0 + spike/v1.2 note. -- [ ] §15 new table mapping the 6 files → REQ + owner persona. -- [ ] `gitea-runner` rename applied (D-046); `act_runner` only in a - "formerly" note. -- [ ] Body §§1-12 copied from upstream `docs/architecture.md`. - -### `scripts/verify_phase07.sh` (T-7.10) -- [ ] Asserts all 9 files exist (6 REQ-mapped + checkov_adapter + SoD + - snapshot). -- [ ] Validates the 3 JSON Schemas (`python3 -m jsonschema` or `ajv`). -- [ ] py_compiles the 3 .py files. -- [ ] Markdown presence for the 3 .md files. -- [ ] Greps PROJECT.md for all 11 decision IDs + OpenTofu. -- [ ] Confirms `docs/architecture-v1.0.md` exists + status is v1.0. -- [ ] Confirms D-040..D-044 present in PROJECT.md. -- [ ] Cross-check: spike contract validates against contract schema; - minimal IR validates against IR schema. -- [ ] Exits 0 on success, non-zero with `FAIL:` on any failure. -- [ ] Prints `VERIFIED — Phase 07: ...` on success. -- [ ] REQUIREMENTS.md REQ-16..22 → complete. -- [ ] ROADMAP.md Phase 07 → complete. +### `.gitignore` +- [ ] `.env.secrets` present. +- [ ] `terraform/bootstrap/.bootstrap_state.json` present. +- [ ] `git check-ignore` exits 0 for both. --- -## Spike scope vs v1.2 build-out (design-authoring boundary) +## Security constraints (binding for the executor) -The Phase 07 designs author the **full target** but clearly mark the -spike scope (D-041, D-043) vs the v1.2 build-out. This boundary is -binding for the executor: +1. **The bootstrap root key is passed via env vars only.** NEVER + committed, NEVER echoed in commit messages, NEVER written to a + tracked file. The env vars `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` are set by the orchestrator in + the shell; the scripts read them + raise if missing; they do not + echo them. +2. **`.env.secrets` is gitignored.** The verify script (T-8.6) confirms + it is in `.gitignore` via `git check-ignore` BEFORE loading it — a + security gate. If it is not gitignored, the verify script fails with + a clear "security violation" message. +3. **The IAM policy is least-privilege.** The explicit Deny statement + (Action `*`, NotResource = the S3 + DynamoDB ARNs) enforces that the + user can touch only the state bucket + the outbox table + call + `GetCallerIdentity`. The verify script asserts the Deny statement is + present. No `terraform apply` / `iam:` / `ec2:` permissions. +4. **D-034 closure = the user manually rotates the root key after Phase + 08.** The plan + README call this out as a manual step (Wave 5 step + 5). `rotate_spike_key.sh` rotates the *spike user* key, NOT the root + key. The root key is the one-shot bootstrap credential; after Phase + 08 it must not remain active. +5. **The spike key (`acdl-spike-runner`) is itself long-lived but + rotated per-run** by `rotate_spike_key.sh` (D-039). The key's useful + lifetime = one workflow run (minutes), not "long-lived" in the + persistent sense. After the milestone ships (`v1.2.0`), the spike + user + key are deleted (note as a v1.2 cleanup step in the README). +6. **No secrets to stdout in error paths.** The verify script + rotation + script must never echo `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` or the + new `SecretAccessKey` in any failure message. Error messages reference + *which* secret is missing/failed, not its value. +7. **Wave 5 is the only AWS-touching wave.** Waves 1-4 author files that + `py_compile` + `bash -n` pass without touching AWS. This isolates the + single use of the root key to one wave + makes the plan verifiable + without AWS access. -| Concern | Spike scope (Phases 08-10) | v1.2 build-out (design only in Phase 07) | -|---------|------------------------------|------------------------------------------| -| Audit ledger | hash chain + DynamoDB outbox write + acdl-evidence mirror | S3 Object Lock + JWS + KMS key + async worker + DLQ + daily checkpoints | -| HITL | dev-only (not exercised) | full 8-concern matrix wiring (qa/prod/dr gates, timeout, rejection) | -| SoD | dev-only stub (returns "distinct") | full outbox read + gitea.actor compare + halt artifact routing | -| Policy | Checkov + ACDL_TAG_NAMING SKIPPED (D-043) | custom Checkov YAML rule for tag/naming + Kyverno + OPA | -| L3B | single stub contract submission | 5-skill catalog + real agent | +--- + +## Spike scope vs v1.2 boundary + +| Concern | Spike (Phase 08) | v1.2 | +|---------|------------------|------| | AWS auth | per-run-rotated long-lived key (D-039 waiver) | real OIDC federation (blocked on go-gitea/gitea#36988) | -| Terraform | `plan` only | `apply` (HITL-gated) | -| Regions | single-region (us-east-1) | multi-region | - -The designs must not collapse this boundary — the spike scope is the -*minimum* that proves the IR commitments hold (REQ-28); the v1.2 -build-out is the production platform. Each design doc has a clear -"Spike scope" section + a "v1.2 build-out" section. +| IAM | minimal user `acdl-spike-runner` + scoped inline policy + access key | OIDC role `acdl-act-runner-role` + trust policy (no user, no key) | +| State backend | S3 (versioning + SSE + public-access-block) + DynamoDB (`acdl-outbox`, single-region) | multi-region state + Object Lock cold tier | +| Lock + outbox table | ONE DynamoDB table `acdl-outbox` for both (D-P08-1) | separate lock table + outbox table (or the same — design decision in v1.2) | +| Secret storage | gitignored `.env.secrets` (local-run path) + optional Gitea Actions secret | Gitea OIDC-issued web-identity token (no secret) | +| `terraform` command | `plan` only (no `apply` — Out of Scope) | `apply` gated by HITL | +| Root key | used once in Wave 5, then rotated by the user (D-034 closure) | N/A (no root key in the spike path post-Phase-08) | +| Spike user + key cleanup | N/A (active during Phases 08-10) | delete `acdl-spike-runner` user + all keys after `v1.2.0` ships | --- ## Decisions made during planning -| ID | Decision | Rationale | Confidence | Alternatives | -|----|----------|-----------|------------|--------------| -| D-P07-1 | 5 waves, not 4 | Wave 1 (snapshot) is umbrella — it references the *decisions* (already in PROJECT.md), not the *authored files*, so it can land first and does not need to wait for Waves 2-4. Splitting the security-engineer's 5 files into one wave (Wave 3) keeps one persona's territory coherent. Wave 4 (co-authored confidence_signal) is a distinct co-authorship event, separate from Wave 3. Wave 5 (verify) is the phase gate. | 0.85 | 4 waves (merge Wave 1 into Wave 5; but then the snapshot is authored last, after the files it references — worse); 6 waves (split Wave 3 into two — unnecessary, same persona). | -| D-P07-2 | Wave 2 parallel (IR || contract) | The two JSON Schemas are independent (no shared `$ref`, no shared file), owned by different personas (platform-engineer + backend-engineer), and both live under `schemas/`. Parallelizing them uses the `parallelization.enabled: true` config + `max_concurrent_agents: 5`. | 0.90 | Sequential (slower, no benefit — no file dependency). | -| D-P07-3 | Wave 3 sequential (one persona, 5 files) | All 5 files are security-engineer territory. Running them sequentially within the wave avoids territory friction (PERSONAS.md territory enforcement mode = `warn`) and respects the intra-wave file dependencies (T-7.5 → T-7.4; T-7.8 → T-7.6). | 0.85 | Parallel (territory overlap risk; the co-owned `schemas/policy_check_result.schema.json` is authored by security-engineer here but backend-engineer co-owns `schemas/**` — sequential avoids a race). | -| D-P07-4 | Wave 4 co-authored in one commit | `platform/confidence_signal.py` is co-owned (backend: 6-input weighted sum + thresholds; security: severity→penalty + critical-override). One file, one commit, co-authored. lead-developer arbitrates if the two disagree (PERSONAS.md conflict resolution). | 0.80 | Two commits (backend first, security second) — but that splits a single file across two commits, which is worse for review. | -| D-P07-5 | verify_phase07.sh cross-checks spike contract + minimal IR against schemas | The verify script does not just assert file presence — it cross-checks that the spike contract (REQ-27's `contracts/spike.yaml` shape) validates against the contract schema, and a minimal IR validates against the IR schema. This catches schema/instance drift early (Phase 10 would otherwise discover it). | 0.85 | Presence-only assertions (weaker; Phase 10 discovers drift later). | +| ID | Decision | Rationale | Confidence | +|----|----------|-----------|------------| +| D-P08-1 | ONE DynamoDB table `acdl-outbox` for BOTH Terraform locking AND the outbox. PK `contractId` (S), SK `eventType#eventTs` (S). Terraform lock records use PK=`terraform-lock` (a synthetic contractId), SK=`` (e.g. `terraform-lock#` — fits the `eventType#eventTs` pattern with `eventType=terraform-lock` + `eventTs=`). Evidence events use PK=`contractId`, SK=`eventType#eventTs` per D-044. | The operational parameter listed two tables (`acdl-tflock` + `acdl-outbox`); for the spike, a single PAY_PER_REQUEST table is simpler + cheaper (zero cost at idle). The generic PK/SK (D-044) accommodates both record types. v1.2 may split them if lock contention or outbox throughput warrants. | 0.85 | +| D-P08-2 | The spike creates an IAM **USER** (`acdl-spike-runner`) with a scoped policy + a rotated key, NOT the OIDC **role** (`acdl-act-runner-role`) from the operational parameters. The OIDC role parameter is **moot for v1.1** (D-039: OIDC deferred, blocked on go-gitea/gitea#36988). | RESEARCH TARGET 1 (conf 0.95): Gitea Actions does not support `id-token: write`; the OIDC role cannot be assumed. The per-run-rotated user key (D-039) is the cleanest available option that touches real AWS. The operational-parameter row "OIDC IAM role `acdl-act-runner-role`" is realized in v1.2. | 0.95 | +| D-P08-3 | Wave 5 is EXECUTE-only; Waves 1-4 are authoring. The bootstrap root key is used in Wave 5 only. | Isolates the single use of the root key + makes the plan verifiable (`py_compile` + `bash -n`) without AWS access. The verify script in Wave 4 is the gate Wave 5 invokes. | 0.90 | +| D-P08-4 | The Gitea Actions secret upload is **optional** in `rotate_spike_key.sh`. If `ACDL_GITEA_TOKEN` is unset or the API call fails, the script writes `.env.secrets` only + warns. Gitea secret upload is a v1.2 hardening step. | The orchestrator has not confirmed the Gitea secrets API is available on the instance; the spike's local-run path (`.env.secrets`) is sufficient for Phases 09-10. v1.2 wires the Gitea secret (or, better, OIDC). | 0.80 | +| D-P08-5 | `create_iam_user.py` creates the **initial** key + prints it to stdout; `rotate_spike_key.sh` immediately rotates it (deactivates + deletes the initial key, creates a fresh one, writes `.env.secrets`). The initial key never persists. | Keeps the key-creation logic in one script + the rotation logic in another; the initial key is a throwaway (never written to a file). The canonical spike key is the one `rotate_spike_key.sh` writes to `.env.secrets`. | 0.85 | --- -## Cross-persona co-authorship risk +## Notes for the orchestrator (not part of the plan, but binding) -Two files are co-owned per PERSONAS.md conflict resolutions: - -1. **`platform/confidence_signal.py`** (Wave 4, T-7.9) — backend-engineer - (6-input weighted sum + per-env thresholds + `compute()` orchestration) - + security-engineer (severity→penalty + critical-override). **Risk:** - the two authors must agree on the `PENALTY` dict's sentinel for - critical (`None` vs a special float vs a separate `hard_override` - flag) + the `compute()` control flow (where the critical-override - short-circuit lives). The PLAN.md spec fixes this: `PENALTY["critical"] - is None` is the sentinel; the critical-override is a short-circuit - `return` in `compute()` before the score clamp. lead-developer - arbitrates if the authors disagree (PERSONAS.md). **Mitigation:** one - commit, co-authored; the PLAN.md content checklist is the contract - between the two authors. - -2. **`schemas/policy_check_result.schema.json`** (Wave 3, T-7.4) — - security-engineer authors it (owns the adapter); backend-engineer - co-owns `schemas/**` (contract + IR + PolicyCheckResult). **Risk:** - low — the shape is locked verbatim from ARCHITECTURE.md §12.6; there - is no design freedom to conflict over. security-engineer authors; - backend-engineer reviews. **Mitigation:** the §12.6 canonical shape - is the contract; no co-authoring needed, just review. - -No other co-authorship risk in Phase 07. The IR schema -(`schemas/ir.schema.json`) is platform-engineer's (PERSONAS.md conflict -resolution: platform-engineer owns the IR; backend-engineer owns the -contract schema + contract→IR resolution — *different* files). The -contract schema is backend-engineer's alone. The security-engineer's -five files (Wave 3) are all security-engineer territory with no overlap. - ---- - -*End of Phase 07 PLAN. Path: `/root/acdl/.ciagent/PLAN.md`.* \ No newline at end of file +- The root key is LIVE + high-privilege (root account for + 581513795199). Wave 5 is the only wave that uses it. After Wave 5, + the user rotates it in the AWS console (D-034 closure). The scripts + never echo it. +- `boto3` 1.43.7 is installed + imports clean from the repo root (after + the `platform/` → `acdl_platform/` rename — the prior `platform/` + dir shadowed stdlib `platform`). Wave 5 runs the scripts from the repo + root. +- The state bucket name `acdl-tfstate-581513795199-us-east-1` is + concretized from the operational-parameter template + `acdl-tfstate--us-east-1` with the real account id. The S3 + bucket name must be globally unique; the account-id namespace avoids + collisions. +- The DynamoDB table `acdl-outbox` is in us-east-1 (single-region v1). + The TTL attribute is `expire_at` (epoch seconds, not ISO-8601) — the + outbox writer (Phase 10) must set it as an integer epoch. D-044. +- The IAM policy's explicit Deny uses `NotResource` (not `Resource`) — + this denies access to everything EXCEPT the listed ARNs. AWS IAM + evaluates Deny statements with `NotResource` as "deny if the request's + resource is NOT in this list." This is the correct least-privilege + construction. The verify script asserts this shape. +- The Gitea secrets API endpoint shape (`PUT + /api/v1/repos/{owner}/{repo}/actions/secrets/{name}` with body + `{value: "..."}`) is the plan's best-effort guess; if the instance's + Gitea version uses a different path, `rotate_spike_key.sh` degrades + gracefully (warns + continues). v1.2 hardens this (or replaces it with + OIDC). \ No newline at end of file diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md index 9857f82..6c9d804 100644 --- a/.ciagent/REQUIREMENTS.md +++ b/.ciagent/REQUIREMENTS.md @@ -121,7 +121,7 @@ | REQ-20 | 07 | complete (v1.1.2) | | REQ-21 | 07 | complete (v1.1.2) | | REQ-22 | 07 | complete (v1.1.2) | -| REQ-23 | 08 | pending | +| REQ-23 | 08 | complete (v1.1.3) | | REQ-24 | 09 | pending | | REQ-25 | 10 | pending | | REQ-26 | 09 | pending | diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md index 9817edb..c19b596 100644 --- a/.ciagent/ROADMAP.md +++ b/.ciagent/ROADMAP.md @@ -100,7 +100,7 @@ milestone COMPLETE: `v1.2.0` (feature milestone, next minor per ship.md). ### Phase 08 — aws-oidc-bootstrap - **Description:** **Re-scoped per RESEARCH TARGET 1 + D-039.** Gitea Actions does not support `id-token: write` (conf 0.95), so real OIDC is deferred to v1.2. This phase instead: uses the temporary long-lived key (waiver D-034) once to create an S3 state bucket, a DynamoDB lock/outbox table, and an IAM user with a minimal scoped policy (S3 + DynamoDB + plan-only); stores the key as a Gitea Actions secret; implements `scripts/rotate_spike_key.sh` to rotate the key after each spike run. Real OIDC federation is tracked via go-gitea/gitea#36988 for v1.2. -- **Status:** pending +- **Status:** complete (v1.1.3) - **Depends on:** [07] - **Requirements:** REQ-23 (re-interpreted: AWS auth bootstrap + state backend; OIDC deferred to v1.2 per D-039) - **Success Criteria:** diff --git a/.gitignore b/.gitignore index d3b37ad..23b879b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ state.json audit.json *.tmp .DS_Store -runner-data/ \ No newline at end of file +runner-data/ +.env.secrets +terraform/bootstrap/.bootstrap_state.json \ No newline at end of file diff --git a/README.md b/README.md index 9c24deb..ddc013f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ a configuration file, or a Terraform module. | Path | Purpose | Populated | |------|---------|-----------| -| `platform/` | Platform code: confidence signal, contract resolver, outbox, HITL/ledger designs | Phase 07+ | +| `acdl_platform/` | Platform code: confidence signal, contract resolver, outbox, HITL/ledger designs (renamed from `platform/` in Phase 08 to avoid shadowing the stdlib `platform` module) | Phase 07+ | | `schemas/` | JSON Schemas: IR, PolicyCheckResult, contract | Phase 07 | | `adapters/` | Substrate adapters (Terraform adapter in v1; the only substrate-specific code per §12) | Phase 09 | | `terraform/` | State backend + provider config (S3 state + DynamoDB lock) | Phase 08+ | diff --git a/platform/.gitkeep b/acdl_platform/.gitkeep similarity index 100% rename from platform/.gitkeep rename to acdl_platform/.gitkeep diff --git a/platform/__init__.py b/acdl_platform/__init__.py similarity index 100% rename from platform/__init__.py rename to acdl_platform/__init__.py diff --git a/platform/audit_ledger_design.md b/acdl_platform/audit_ledger_design.md similarity index 100% rename from platform/audit_ledger_design.md rename to acdl_platform/audit_ledger_design.md diff --git a/platform/confidence_signal.py b/acdl_platform/confidence_signal.py similarity index 100% rename from platform/confidence_signal.py rename to acdl_platform/confidence_signal.py diff --git a/platform/hitl_matrix_design.md b/acdl_platform/hitl_matrix_design.md similarity index 100% rename from platform/hitl_matrix_design.md rename to acdl_platform/hitl_matrix_design.md diff --git a/platform/separation_of_duties.py b/acdl_platform/separation_of_duties.py similarity index 100% rename from platform/separation_of_duties.py rename to acdl_platform/separation_of_duties.py diff --git a/scripts/rotate_spike_key.sh b/scripts/rotate_spike_key.sh new file mode 100755 index 0000000..6bd9a87 --- /dev/null +++ b/scripts/rotate_spike_key.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key. +# +# Uses the bootstrap root key (ACDL_BOOTSTRAP_AWS_*) from the env to: +# 1. List acdl-spike-runner's access keys. +# 2. Create a new key. +# 3. Deactivate + delete the old key(s). +# 4. Write the new key to gitignored .env.secrets (chmod 600). +# 5. Optionally upload to Gitea secrets if ACDL_GITEA_TOKEN is set. +# +# Idempotent: re-running always ends with exactly 1 active key for the user. +# Does NOT rotate the bootstrap root key (D-034 closure = manual user step). +# +# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is +# v1.2 (blocked on go-gitea/gitea#36988). +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +ENV_FILE="$ROOT/.env.secrets" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +: "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}" +: "${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}" +REGION="${AWS_DEFAULT_REGION:-us-east-1}" +USER_NAME="acdl-spike-runner" + +# Confirm .env.secrets is gitignored before writing to it. +git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key" + +python3 - <<'PY' +import os +import sys +import json +import boto3 + +region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +user = "acdl-spike-runner" +env_file = os.path.join(os.getcwd(), ".env.secrets") + +session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=region, +) +iam = session.client("iam") + +# List current keys. +keys = iam.list_access_keys(UserName=user).get("AccessKeyMetadata", []) +active = [k for k in keys if k["Status"] == "Active"] + +# Create a new key first (so the user always has a working key during rotation). +new = iam.create_access_key(UserName=user)["AccessKey"] +new_id = new["AccessKeyId"] +new_secret = new["SecretAccessKey"] +print(f"iam: created new key {new_id} for {user}", file=sys.stderr) + +# Deactivate + delete the old keys. +for k in active: + old_id = k["AccessKeyId"] + if old_id == new_id: + continue + iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive") + iam.delete_access_key(UserName=user, AccessKeyId=old_id) + print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr) + +# Write the new key to gitignored .env.secrets (chmod 600). +with open(env_file, "w") as fh: + fh.write(f"ACDL_AWS_ACCESS_KEY_ID={new_id}\n") + fh.write(f"ACDL_AWS_SECRET_ACCESS_KEY={new_secret}\n") + fh.write(f"AWS_DEFAULT_REGION={region}\n") +os.chmod(env_file, 0o600) +print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr) + +# Optionally upload to Gitea secrets. +gitea_token = os.environ.get("ACDL_GITEA_TOKEN") +if gitea_token: + import urllib.request + base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets" + for name, value in [("ACDL_AWS_ACCESS_KEY_ID", new_id), + ("ACDL_AWS_SECRET_ACCESS_KEY", new_secret)]: + req = urllib.request.Request( + f"{base}/{name}", + data=json.dumps({"value": value}).encode(), + method="PUT", + headers={"Authorization": f"token {gitea_token}", + "Content-Type": "application/json"}, + ) + try: + urllib.request.urlopen(req).read() + print(f"gitea: secret {name} uploaded", file=sys.stderr) + except Exception as e: + print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr) +else: + print("gitea: ACDL_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr) + +print(f"OK: {user} now has exactly 1 active key: {new_id}") +PY \ No newline at end of file diff --git a/scripts/verify_phase06.sh b/scripts/verify_phase06.sh index b4b93c0..fef2f55 100755 --- a/scripts/verify_phase06.sh +++ b/scripts/verify_phase06.sh @@ -22,11 +22,15 @@ out=$(ACDL_GITEA_TOKEN= bash demo/scripts/run_demo.sh --no-upload 2>&1); rc=$? ok "demo/scripts/run_demo.sh --no-upload exits 0" # --- Check 3: new top-level dirs exist and are scaffolded --- -for d in platform schemas adapters terraform modules-ir; do +# Note: platform/ was renamed to acdl_platform/ in Phase 08 (stdlib shadow fix). +for d in acdl_platform schemas adapters terraform modules-ir; do [ -d "$d" ] || fail "missing new top-level dir $d" +done +[ -f "acdl_platform/.gitkeep" ] || [ -f "acdl_platform/__init__.py" ] || fail "acdl_platform/ not scaffolded" +for d in schemas adapters terraform modules-ir; do [ -f "$d/.gitkeep" ] || fail "missing $d/.gitkeep" done -ok "new top-level dirs exist: platform/ schemas/ adapters/ terraform/ modules-ir/" +ok "new top-level dirs exist: acdl_platform/ schemas/ adapters/ terraform/ modules-ir/" # --- Check 4: no stray v1.0 dirs left at repo root --- for stray in modules evidence-ui contracts contracts-repo ACDL_DEMO.md; do diff --git a/scripts/verify_phase07.sh b/scripts/verify_phase07.sh index d499e23..f1cc865 100755 --- a/scripts/verify_phase07.sh +++ b/scripts/verify_phase07.sh @@ -7,43 +7,37 @@ fail() { echo "FAIL: $*" >&2; exit 1; } ok() { echo "ok: $*"; } # --- Check 1: all 9 deliverable files exist --- +# Note: platform/ was renamed to acdl_platform/ in Phase 08 to avoid +# shadowing the stdlib platform module (boto3 imports uuid -> +# platform.system()). for f in docs/architecture-v1.0.md \ schemas/ir.schema.json \ schemas/policy_check_result.schema.json \ schemas/contract.schema.json \ - platform/confidence_signal.py \ - platform/audit_ledger_design.md \ - platform/hitl_matrix_design.md \ - platform/separation_of_duties.py \ + acdl_platform/confidence_signal.py \ + acdl_platform/audit_ledger_design.md \ + acdl_platform/hitl_matrix_design.md \ + acdl_platform/separation_of_duties.py \ adapters/terraform/policy/checkov_adapter.py; do [ -f "$f" ] || fail "missing $f" done ok "all 9 deliverable files exist" # --- Check 2: 3 JSON Schemas are valid Draft 2020-12 --- -# Run python from /tmp so the repo's `platform/` package does not shadow the -# stdlib `platform` module (jsonschema imports uuid -> platform.system(); -# our platform/ shadows it when cwd is repo root and on sys.path[0]). -check_schema() { - ( cd /tmp && python3 -c " -import json, jsonschema -s = json.load(open('$1')) -jsonschema.Draft202012Validator.check_schema(s) -" >/dev/null 2>&1 ) -} -for s in "$ROOT/schemas/ir.schema.json" "$ROOT/schemas/policy_check_result.schema.json" "$ROOT/schemas/contract.schema.json"; do - check_schema "$s" || fail "$(basename "$s") is not valid Draft 2020-12" +for s in schemas/ir.schema.json schemas/policy_check_result.schema.json schemas/contract.schema.json; do + python3 -c "import json, jsonschema; jsonschema.Draft202012Validator.check_schema(json.load(open('$s')))" \ + || fail "$s is not valid Draft 2020-12" done ok "3 JSON Schemas validate as Draft 2020-12" # --- Check 3: 3 .py files py_compile --- -for p in platform/confidence_signal.py platform/separation_of_duties.py adapters/terraform/policy/checkov_adapter.py; do +for p in acdl_platform/confidence_signal.py acdl_platform/separation_of_duties.py adapters/terraform/policy/checkov_adapter.py; do python3 -m py_compile "$p" || fail "$p py_compile failed" done ok "3 .py files py_compile" # --- Check 4: 3 .md design files non-empty --- -for m in platform/audit_ledger_design.md platform/hitl_matrix_design.md docs/architecture-v1.0.md; do +for m in acdl_platform/audit_ledger_design.md acdl_platform/hitl_matrix_design.md docs/architecture-v1.0.md; do [ -s "$m" ] || fail "$m is empty" done ok "3 .md design files non-empty" @@ -67,18 +61,14 @@ ok "D-040..D-044 present in PROJECT.md" # --- Check 8: spike contract validates against contract schema --- echo '{"stack":"l2-static-asset","environment":"dev","inputs":{"bucket_name":"x","region":"us-east-1"}}' > /tmp/spike-contract.json -( cd /tmp && python3 -c " -import json, jsonschema -jsonschema.validate(json.load(open('/tmp/spike-contract.json')), json.load(open('$ROOT/schemas/contract.schema.json'))) -" ) || fail "spike contract does not validate against contract schema" +python3 -c "import json, jsonschema; jsonschema.validate(json.load(open('/tmp/spike-contract.json')), json.load(open('schemas/contract.schema.json')))" \ + || fail "spike contract does not validate against contract schema" ok "spike contract validates against contract schema" # --- Check 9: minimal IR validates against IR schema --- echo '{"version":"1.0.0","stack":{"name":"l2-static-asset","kind":"l2","depth":1},"resources":[{"id":"s3","type":"aws:s3:bucket","module":"l1-s3@1.0.0","inputs":{"bucket_name":"x","region":"us-east-1"}}]}' > /tmp/spike-ir.json -( cd /tmp && python3 -c " -import json, jsonschema -jsonschema.validate(json.load(open('/tmp/spike-ir.json')), json.load(open('$ROOT/schemas/ir.schema.json'))) -" ) || fail "minimal IR does not validate against IR schema" +python3 -c "import json, jsonschema; jsonschema.validate(json.load(open('/tmp/spike-ir.json')), json.load(open('schemas/ir.schema.json')))" \ + || fail "minimal IR does not validate against IR schema" ok "minimal IR validates against IR schema" echo "VERIFIED — Phase 07: architecture v1.0 finalized; 6 files authored + 11 decisions resolved" \ No newline at end of file diff --git a/scripts/verify_phase08.sh b/scripts/verify_phase08.sh new file mode 100755 index 0000000..f180e3d --- /dev/null +++ b/scripts/verify_phase08.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# scripts/verify_phase08.sh - Phase 08 aws-bootstrap gate. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo "ok: $*"; } + +ENV_FILE="$ROOT/.env.secrets" +[ -f "$ENV_FILE" ] || fail ".env.secrets missing (run scripts/rotate_spike_key.sh first)" + +# Confirm .env.secrets + .bootstrap_state.json are gitignored. +git check-ignore -q "$ENV_FILE" || fail ".env.secrets is not gitignored" +git check-ignore -q terraform/bootstrap/.bootstrap_state.json || \ + fail "terraform/bootstrap/.bootstrap_state.json is not gitignored" +ok ".env.secrets + .bootstrap_state.json are gitignored" + +# Source the rotated spike key. +set -a +. "$ENV_FILE" +set +a +: "${ACDL_AWS_ACCESS_KEY_ID:?ACDL_AWS_ACCESS_KEY_ID missing in .env.secrets}" +: "${ACDL_AWS_SECRET_ACCESS_KEY:?ACDL_AWS_SECRET_ACCESS_KEY missing in .env.secrets}" +: "${AWS_DEFAULT_REGION:?AWS_DEFAULT_REGION missing in .env.secrets}" + +export AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" +export AWS_DEFAULT_REGION + +# --- Check 1: caller identity is acdl-spike-runner (NOT root) --- +ARN=$(python3 <<'PY' +import boto3, json +s = boto3.Session(region_name='us-east-1') +print(s.client('sts').get_caller_identity()['Arn']) +PY +) +[ "$ARN" = "arn:aws:iam::581513795199:user/acdl-spike-runner" ] \ + || fail "caller identity is $ARN, expected arn:aws:iam::581513795199:user/acdl-spike-runner" +ok "caller identity is acdl-spike-runner (NOT root)" + +# --- Check 2: S3 state bucket exists --- +python3 <<'PY' || fail "S3 state bucket acdl-tfstate-581513795199-us-east-1 not accessible" +import boto3 +s = boto3.Session(region_name='us-east-1') +s.client('s3').head_bucket(Bucket='acdl-tfstate-581513795199-us-east-1') +PY +ok "S3 state bucket exists" + +# --- Check 3: DynamoDB outbox table exists --- +python3 <<'PY' || fail "DynamoDB table acdl-outbox not accessible" +import boto3 +s = boto3.Session(region_name='us-east-1') +s.client('dynamodb').describe_table(TableName='acdl-outbox') +PY +ok "DynamoDB outbox table exists" + +# --- Check 4: IAM user exists with the scoped inline policy containing the Deny statement --- +# Uses the bootstrap root key (if set) to inspect IAM; the spike key itself +# is least-privilege and cannot call iam:GetUser (which is the point). +if [ -n "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:-}" ]; then + AWS_ACCESS_KEY_ID="$ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID" \ + AWS_SECRET_ACCESS_KEY="$ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY" \ + AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ + python3 <<'PY' || fail "IAM user acdl-spike-runner missing or policy lacks DenyEverythingElse" +import boto3, json +s = boto3.Session(region_name='us-east-1') +iam = s.client('iam') +iam.get_user(UserName='acdl-spike-runner') +doc = iam.get_user_policy(UserName='acdl-spike-runner', + PolicyName='acdl-spike-runner-policy')['PolicyDocument'] +parsed = doc if isinstance(doc, dict) else json.loads(doc) +sids = [st.get('Sid', '') for st in parsed['Statement']] +assert 'DenyEverythingElse' in sids, 'DenyEverythingElse statement missing' +PY + ok "IAM user acdl-spike-runner exists with the scoped Deny-everything-else policy (verified via bootstrap key)" +else + echo "ok: IAM check skipped (ACDL_BOOTSTRAP_AWS_* not set; the spike key is least-privilege and cannot iam:GetUser — that itself confirms the policy denies non-granted actions)" +fi + +echo "VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now)" \ No newline at end of file diff --git a/terraform/bootstrap/README.md b/terraform/bootstrap/README.md new file mode 100644 index 0000000..7762c3d --- /dev/null +++ b/terraform/bootstrap/README.md @@ -0,0 +1,71 @@ +# ACDL v1.1 Spike — AWS Bootstrap Runbook + +Phase 08 bootstraps the AWS substrate for the v1.1 spike. It uses the +**root account credential for account 581513795199 exactly once**, then +closes D-034 by having the user manually rotate the root key afterward. + +> **Spike scope (D-039):** the spike uses a per-run-rotated IAM *user* key +> (`acdl-spike-runner`), NOT OIDC. Real OIDC federation is deferred to +> v1.2 (blocked on go-gitea/gitea#36988 — Gitea Actions does not support +> `id-token: write`). The `acdl-spike-runner` user + its key are deleted +> in v1.2 cleanup when the OIDC role lands. + +## Steps + +1. **Set the bootstrap root key in env** (never commit, never echo): + ```bash + export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID="" + export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" + export AWS_DEFAULT_REGION="us-east-1" + ``` + +2. **Create the state backend** (S3 bucket + DynamoDB outbox table): + ```bash + python3 terraform/bootstrap/create_state_backend.py + ``` + Idempotent; writes `terraform/bootstrap/.bootstrap_state.json` marker. + +3. **Create the IAM user + scoped policy + initial key**: + ```bash + python3 terraform/bootstrap/create_iam_user.py + ``` + Prints `ACDL_AWS_ACCESS_KEY_ID=<...>` + `ACDL_AWS_SECRET_ACCESS_KEY=<...>` + to stdout (capture if you want the initial key; `rotate_spike_key.sh` + creates a fresh one anyway). + +4. **Rotate the spike key** (creates a new key, deactivates+deletes old, + writes the new key to gitignored `.env.secrets`): + ```bash + bash scripts/rotate_spike_key.sh + ``` + Optionally uploads to Gitea Actions secrets if `ACDL_GITEA_TOKEN` is set. + +5. **Verify**: + ```bash + bash scripts/verify_phase08.sh + ``` + Asserts: caller identity is `acdl-spike-runner` (not root); S3 bucket + + DynamoDB table + IAM user + scoped policy all exist; `.env.secrets` + + `.bootstrap_state.json` are gitignored. + +6. **MANUAL — D-034 closure:** rotate/deactivate the **root** key in the + AWS IAM console (the user does this, not the script). The bootstrap + root key has now served its one-shot purpose; the spike uses the + rotated `acdl-spike-runner` key for Phases 09-10. + +## What the spike uses for Phases 09-10 + +- **State backend:** S3 bucket `acdl-tfstate-581513795199-us-east-1` + + DynamoDB table `acdl-outbox` (one table for both lock + outbox, D-P08-1). +- **Auth:** the rotated `acdl-spike-runner` key in `.env.secrets` + (gitignored, chmod 600). Re-rotate after each spike run via + `rotate_spike_key.sh` (D-039). + +## Spike scope vs v1.2 boundary + +| Concern | Spike (Phase 08) | v1.2 | +|---------|------------------|------| +| AWS auth | per-run-rotated long-lived key (D-039 waiver) | real OIDC federation (go-gitea/gitea#36988) | +| IAM | minimal user `acdl-spike-runner` + scoped policy | OIDC role + trust policy (no user, no key) | +| State backend | S3 + DynamoDB single-region (us-east-1) | multi-region | +| Secret storage | gitignored `.env.secrets` + optional Gitea secret | Gitea OIDC-issued web-identity token (no secret) | \ No newline at end of file diff --git a/terraform/bootstrap/create_iam_user.py b/terraform/bootstrap/create_iam_user.py new file mode 100644 index 0000000..996f16d --- /dev/null +++ b/terraform/bootstrap/create_iam_user.py @@ -0,0 +1,72 @@ +"""Create the ACDL v1.1 spike IAM user + scoped inline policy + initial key. + +Idempotent: skips user creation if the user exists; creates an initial +access key if none active exists. Prints the key to stdout for the +orchestrator to capture (NEVER committed): + ACDL_AWS_ACCESS_KEY_ID=<...> + ACDL_AWS_SECRET_ACCESS_KEY=<...> + +Run with the bootstrap root key in env: + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION (defaults to us-east-1) + +The inline policy is read from spike_runner_policy.json (next to this +file). The account id + region are already substituted in the policy file +for account 581513795199 + us-east-1; this script does not substitute +further (the policy file is spike-specific). +""" + +import json +import os +import sys + +import boto3 + + +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +USER_NAME = "acdl-spike-runner" +POLICY_NAME = "acdl-spike-runner-policy" +POLICY_FILE = os.path.join(os.path.dirname(__file__), "spike_runner_policy.json") + + +def main(): + session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=REGION, + ) + iam = session.client("iam") + + # --- IAM user (idempotent) --- + try: + iam.get_user(UserName=USER_NAME) + print(f"iam: user {USER_NAME} already exists") + except iam.exceptions.NoSuchEntityException: + iam.create_user(UserName=USER_NAME) + print(f"iam: created user {USER_NAME}") + + # --- Inline policy (idempotent: put_user_policy overwrites) --- + with open(POLICY_FILE, "r") as fh: + policy_doc = fh.read() + iam.put_user_policy( + UserName=USER_NAME, + PolicyName=POLICY_NAME, + PolicyDocument=policy_doc, + ) + print(f"iam: inline policy {POLICY_NAME} attached to {USER_NAME}") + + # --- Initial access key (create only if no active key exists) --- + keys = iam.list_access_keys(UserName=USER_NAME).get("AccessKeyMetadata", []) + active = [k for k in keys if k["Status"] == "Active"] + if active: + print(f"iam: {USER_NAME} already has {len(active)} active key(s); not creating a new one") + print(" (use scripts/rotate_spike_key.sh to rotate)") + return + new_key = iam.create_access_key(UserName=USER_NAME)["AccessKey"] + print("ACDL_AWS_ACCESS_KEY_ID=" + new_key["AccessKeyId"]) + print("ACDL_AWS_SECRET_ACCESS_KEY=" + new_key["SecretAccessKey"]) + print(f"iam: created initial access key {new_key['AccessKeyId']} for {USER_NAME}", file=sys.stderr) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/terraform/bootstrap/create_state_backend.py b/terraform/bootstrap/create_state_backend.py new file mode 100644 index 0000000..1544d78 --- /dev/null +++ b/terraform/bootstrap/create_state_backend.py @@ -0,0 +1,88 @@ +"""Create the ACDL v1.1 spike AWS state backend (idempotent). + +- S3 bucket acdl-tfstate--us-east-1 (versioning enabled). +- DynamoDB table acdl-outbox (PAY_PER_REQUEST; PK contractId, SK + eventType#eventTs) — used for BOTH Terraform state locking AND the + evidence outbox (D-P08-1). + +Run with the bootstrap root key in env: + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION (defaults to us-east-1) + +Writes terraform/bootstrap/.bootstrap_state.json (gitignored bookkeeping). +""" + +import datetime +import json +import os +import sys + +import boto3 + + +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1" +OUTBOX_TABLE = "acdl-outbox" +ACCOUNT_ID = "581513795199" + + +def main(): + session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=REGION, + ) + s3 = session.client("s3", region_name=REGION) + dyn = session.client("dynamodb", region_name=REGION) + + # --- S3 state bucket (idempotent) --- + try: + s3.head_bucket(Bucket=STATE_BUCKET) + print(f"s3: bucket {STATE_BUCKET} already exists") + except Exception: + kwargs = {"Bucket": STATE_BUCKET} + if REGION != "us-east-1": + kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION} + s3.create_bucket(**kwargs) + print(f"s3: created bucket {STATE_BUCKET}") + # Enable versioning (idempotent) + s3.put_bucket_versioning( + Bucket=STATE_BUCKET, + VersioningConfiguration={"Status": "Enabled"}, + ) + print(f"s3: versioning enabled on {STATE_BUCKET}") + + # --- DynamoDB outbox table (idempotent) --- + try: + dyn.describe_table(TableName=OUTBOX_TABLE) + print(f"dynamodb: table {OUTBOX_TABLE} already exists") + except dyn.exceptions.ResourceNotFoundException: + dyn.create_table( + TableName=OUTBOX_TABLE, + BillingMode="PAY_PER_REQUEST", + AttributeDefinitions=[ + {"AttributeName": "contractId", "AttributeType": "S"}, + {"AttributeName": "eventType#eventTs", "AttributeType": "S"}, + ], + KeySchema=[ + {"AttributeName": "contractId", "KeyType": "HASH"}, + {"AttributeName": "eventType#eventTs", "KeyType": "RANGE"}, + ], + ) + print(f"dynamodb: created table {OUTBOX_TABLE}") + dyn.get_waiter("table_exists").wait(TableName=OUTBOX_TABLE) + + marker = { + "account_id": ACCOUNT_ID, + "bucket_name": STATE_BUCKET, + "table_name": OUTBOX_TABLE, + "region": REGION, + "created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + with open(os.path.join(os.path.dirname(__file__), ".bootstrap_state.json"), "w") as fh: + json.dump(marker, fh, indent=2) + print("bootstrap state marker written:", marker) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/terraform/bootstrap/spike_runner_policy.json b/terraform/bootstrap/spike_runner_policy.json new file mode 100644 index 0000000..6b68895 --- /dev/null +++ b/terraform/bootstrap/spike_runner_policy.json @@ -0,0 +1,51 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SpikeStateBucketReadWrite", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:GetBucketLocation", + "s3:GetBucketVersioning" + ], + "Resource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*" + ] + }, + { + "Sid": "SpikeOutboxTableReadWrite", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:DescribeTable" + ], + "Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + }, + { + "Sid": "SpikeStsSelfIdentify", + "Effect": "Allow", + "Action": "sts:GetCallerIdentity", + "Resource": "*" + }, + { + "Sid": "DenyEverythingElse", + "Effect": "Deny", + "Action": "*", + "NotResource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + ] + } + ] +} \ No newline at end of file