Files
acdl/.ciagent/PLAN.md
T
Jon Chery a003168b3a docs(P08): create Phase 08 plan (aws-bootstrap)
---ci---
project: acdl
phase: 8
milestone: v1.1
status: plan
plan:
  waves: 5
  tasks: 8
  requirements: [REQ-23]
---/ci---

Phase 08 plan authored by ci-planner. 5 waves:
- Wave 1 (security): T-8.1 spike_runner_policy.json (least-privilege)
- Wave 2 (platform): T-8.2/T-8.3 create_state_backend.py, T-8.4 create_iam_user.py
- Wave 3 (platform): T-8.5 rotate_spike_key.sh
- Wave 4 (lead): T-8.6 verify_phase08.sh, T-8.7 README + .gitignore
- Wave 5 (lead, EXECUTE-only): T-8.8 run bootstrap against AWS + D-034 closure

7 authored files. Key decisions: D-P08-1 (one DynamoDB table acdl-outbox
for both lock + outbox), D-P08-2 (IAM user acdl-spike-runner not OIDC
role; OIDC deferred to v1.2 per D-039), D-P08-3 (Wave 5 EXECUTE-only),
D-P08-4 (optional Gitea secret upload), D-P08-5 (initial key is
throwaway).

Security: root key via env vars only (never committed); .env.secrets
gitignored; IAM policy explicit Deny-everything-else; D-034 closure =
user manually rotates root key post-phase.
2026-07-21 18:57:31 +00:00

64 KiB

phase, name, milestone, milestone_type, status, requirements, must_haves, verification
phase name milestone milestone_type status requirements must_haves verification
8 aws-bootstrap v1.1 feature planned
REQ-23
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}
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)
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/<name> 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).
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)'
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 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)
typecheck test build
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 scripts/verify_phase08.sh no-op # terraform init is Phase 09; Phase 08 is infra bootstrap via boto3, not Terraform

Phase 08 — aws-bootstrap PLAN

Goal

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.

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.

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 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-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).

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 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").

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.

Five waves, strictly ordered:

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 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 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 — security-engineer: REQ-23b policy contract

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-8.1 — Author terraform/bootstrap/spike_runner_policy.json

  • 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. JSON IAM policy documentVersion: "2012-10-17", Statement: [...]. This is the AWS IAM policy language, not JSON Schema.
    2. Statement 1 — S3 ALLOW
      {
        "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-<account-id>-us-east-1 concretized with the real account id.
    3. Statement 2 — DynamoDB ALLOW
      {
        "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
      {
        "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)
      {
        "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:
    • 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-<account-id>-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: 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:
    • 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: REQ-23a state backend + REQ-23b IAM user

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-8.2 — Author terraform/bootstrap/create_state_backend.py header + contract

(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. 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. Importsimport 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: <region>. 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:
      {
        "account_id": "581513795199",
        "bucket_name": "acdl-tfstate-581513795199-us-east-1",
        "table_name": "acdl-outbox",
        "region": "us-east-1",
        "created_at": "<ISO-8601 UTC now>",
        "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.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-<account-id>-us-east-1).
    • RESEARCH.md TARGET 5 (DynamoDB outbox pattern, RPO=0, on-demand, PK/SK, TTL).
  • Commit message: 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:
    • 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-8.4 — Author terraform/bootstrap/create_iam_user.py

  • 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. 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. Importsimport 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 <AKIA...> 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=<AccessKeyId>
        ACDL_AWS_SECRET_ACCESS_KEY=<SecretAccessKey>
        
        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:
    • 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: 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:
    • 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 3 — platform-engineer: D-039 key rotation script

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-8.5 — Author scripts/rotate_spike_key.sh

  • 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. 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. ConstantsUSER_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":"<new access key id>"} + 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:
      ACDL_AWS_ACCESS_KEY_ID=<...>
      ACDL_AWS_SECRET_ACCESS_KEY=<...>
      
      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:
    • 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: 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:
    • 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 4 — lead-developer: verify script + README + .gitignore

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-8.6 — Author scripts/verify_phase08.sh

  • 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. ConstantsREGION="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 existspython3 - <<'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 existspython3 - <<'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 policypython3 - <<'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 contentspython3 -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: <reason> to stderr + exit 1. Do NOT print any secret in any error message.
  • Source citations:
    • 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: 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:
    • 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:
        export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID='<root key id>'
        export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY='<root secret>'
        
      2. Create the state backend:
        python3 terraform/bootstrap/create_state_backend.py
        
      3. Create the IAM user + initial key (capture stdout):
        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 scripts/rotate_spike_key.sh
        
      5. Verify:
        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 (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)
  • 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.

Explicit per-file content checklist (consolidated)

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.

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).

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).

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).

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.

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).

.gitignore

  • .env.secrets present.
  • terraform/bootstrap/.bootstrap_state.json present.
  • git check-ignore exits 0 for both.

Security constraints (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.

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)
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
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=<lock-id> (e.g. terraform-lock#<lock-hash> — fits the eventType#eventTs pattern with eventType=terraform-lock + eventTs=<lock-id>). 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

Notes for the orchestrator (not part of the plan, but binding)

  • 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-<account-id>-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).