---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.
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 |
|
|
|
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.shafter 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-23a → T-8.3 (
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.pyreadsspike_runner_policy.jsonand substitutes the account id + region into the ARNs). - Wave 3 → Wave 2 (
rotate_spike_key.shreferences theacdl-spike-runneruser name + the scoped policy that Wave 2 creates; the script is authored against that contract). - Wave 4 → all prior (
verify_phase08.shasserts 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); createterraform/bootstrap/__init__.py(empty) + aterraform/bootstrap/.gitkeepif 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):
- JSON IAM policy document —
Version: "2012-10-17",Statement: [...]. This is the AWS IAM policy language, not JSON Schema. - Statement 1 — S3 ALLOW —
Both the bucket ARN (for
{ "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/*" ] }ListBucket) and the object ARN prefix (forPutObject/GetObject/DeleteObject) — AWS requires both. The account id581513795199is concrete (the orchestrator verified it viasts.get_caller_identity()→arn:aws:iam::581513795199:root). The bucket name follows the operational-parameter templateacdl-tfstate-<account-id>-us-east-1concretized with the real account id. - Statement 2 — DynamoDB ALLOW —
The outbox table ARN. D-044 locks the table name
{ "Sid": "SpikeDynamoDBOutboxAccess", "Effect": "Allow", "Action": [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem" ], "Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" }acdl-outbox(PKcontractId, SKeventType#eventTs,PAY_PER_REQUEST). - Statement 3 — STS ALLOW —
{ "Sid": "SpikeStsGetCallerIdentity", "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" }sts:GetCallerIdentitydoes not support resource-level perms (AWS requiresResource: "*"); this is the verify-step permission. - Statement 4 — explicit Deny-everything-else (least privilege) —
An explicit
{ "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" ] }DenywithAction: "*"+NotResourcelisting the above ARNs. This enforces least privilege: the user can only touch the state bucket + the outbox table + callGetCallerIdentity; everything else is denied. (Note:sts:GetCallerIdentityis allowed separately in Statement 3; the Deny'sNotResourceexcludes the S3 + DynamoDB ARNs but does not list*for STS — STSGetCallerIdentityis not resource-scoped, so the Deny'sNotResourcedoes 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.) - NO
terraform applypermission — the spike is plan-only (Out of Scope table in REQUIREMENTS.md + PROJECT.md operational parameter "Spiketerraformcommand =planonly"). The policy does not grant any IAM permission to create/modify resources beyond the state bucket + outbox table. The user cannot runterraform applyagainst arbitrary AWS because it has no permissions on any resource type other than S3 (the state bucket) + DynamoDB (the outbox).terraform planonly reads (with the AWS provider querying resources the user has no access to — those reads return empty/forbidden, which is fine for the spike'sl1-s3plan that only declares a single new bucket). $comment-equivalent — JSON has no comments; include a_dockey 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)."
- JSON IAM policy document —
- Source citations:
PROJECT.mdD-039 (per-run-rotated long-lived key waiver; OIDC deferred to v1.2).PROJECT.mdoperational parameters (region us-east-1; state bucketacdl-tfstate-<account-id>-us-east-1; lock tableacdl-tflock— NOTE: D-P08-1 below consolidates the lock table intoacdl-outbox, so the policy grants DynamoDB onacdl-outbox, notacdl-tflock).PROJECT.mdD-044 (outboxPAY_PER_REQUEST, PKcontractId, SKeventType#eventTs, TTLexpire_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.mdREQ-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(thenfeat(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).Versionis"2012-10-17".- 4 statements: S3 Allow, DynamoDB Allow, STS Allow, Deny.
- The Deny statement has
Action: "*"+NotResourcelisting the S3 + DynamoDB ARNs (not STS — STS GetCallerIdentity is not resource-scoped). - No
terraformoriam:orec2:actions in any Allow statement (least privilege — the user cannot create/modify infra). - Account id
581513795199is concrete in all ARNs. - Bucket name
acdl-tfstate-581513795199-us-east-1matches 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):
- 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)."
- Imports —
import boto3, os, json, datetime, sys. No third-party deps beyond boto3. - Constants —
REGION = "us-east-1"(operational parameter).ACCOUNT_ID = "581513795199"(concrete — verified; could also be derived at runtime viasts.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).
_client(service)— build a boto3 client with the bootstrap creds from env vars (AWS_ACCESS_KEY_IDfromACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID, etc.) + region. Raise a clear error if the env vars are missing.create_s3_bucket()—head_bucket(Bucket=BUCKET_NAME)first; if it succeeds, the bucket exists → skip creation, log "S3 bucket already exists".- If
head_bucketraisesClientErrorwith404/NoSuchBucket, create the bucket. Note: forus-east-1theCreateBucketConfigurationLocationConstraintmust NOT be set (us-east-1 is the default; setting it for us-east-1 raisesInvalidLocationConstraint). For any other region you would setLocationConstraint: <region>. The script assertsREGION == "us-east-1"at the top + comments this gotcha. - Enable versioning on the bucket
(
put_bucket_versioning(Bucket=..., VersioningConfiguration={...})withStatus="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_blockwith all four blocksTrue) — the state bucket must never be public.
create_dynamodb_table()—describe_table(TableName=TABLE_NAME)first; if it succeeds, the table exists → skip creation, log "DynamoDB table already exists".- If
describe_tableraisesResourceNotFoundException, 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"}](PKcontractId, SKeventType#eventTsper D-044).- Enable TTL via a separate
update_time_to_livecall after creation:TimeToLiveSpecification={AttributeName="expire_at", Enabled=True}. The writer setsexpire_at = now + 365d(D-044). Note: TTL attribute isexpire_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 adescribe_tablepoll loop).
write_bootstrap_state_marker()— writeterraform/bootstrap/.bootstrap_state.json(gitignored — T-8.7) with:This is bookkeeping, NOT secrets. The verify script (T-8.6) asserts it exists + is gitignored.{ "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" }main()— call_clientchecks,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).- Idempotency — both
create_*functions skip-if-exists. Re-running the script is safe (the bootstrap state marker is overwritten with a freshcreated_at— that's fine; it's a "last bootstrap ran at" marker, not a "first creation" record). - 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.mdD-044 (outbox PAY_PER_REQUEST, PK contractId, SK eventType#eventTs, TTL expire_at = now+365d).PROJECT.mdoperational parameters (region us-east-1; state bucketacdl-tfstate-<account-id>-us-east-1).RESEARCH.mdTARGET 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(thenfeat(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.pyexits 0.boto3is the only third-party import.- The script reads
ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID+ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEYfrom env (grep for the env var names). us-east-1LocationConstraintgotcha is commented (noCreateBucketConfigurationfor us-east-1).- Versioning is enabled (
put_bucket_versioning). - DynamoDB
BillingMode="PAY_PER_REQUEST", PKcontractId, SKeventType#eventTs, TTLexpire_at. - Both
create_*functions are skip-if-exists (idempotent). .bootstrap_state.jsonis 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):
- 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."
- Imports —
import boto3, os, json, sys. - 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").
_client(service)— same pattern as T-8.3.create_user()—get_user(UserName=USER_NAME)first; if it succeeds, the user exists → skip creation, log "IAM user already exists".- If
get_userraisesNoSuchEntity,create_user(UserName=...).
attach_inline_policy()—- Read
spike_runner_policy.json(T-8.1). The policy's ARNs are already concrete (581513795199+us-east-1are 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_policyoverwrites if the policy already exists (this is fine — it keeps the attached policy in sync with the file).
- Read
create_access_key()—- List the user's access keys:
list_access_keys(UserName=USER_NAME). - If ≥1
Activekey exists: log that a key exists, print itsAccessKeyId(NOT the secret — the secret is only available at creation time), and do NOT create a new one (the orchestrator should runrotate_spike_key.shto 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:to stdout (the orchestrator captures these — they are NEVER committed, NEVER written to a tracked file). The script does NOT write them toACDL_AWS_ACCESS_KEY_ID=<AccessKeyId> ACDL_AWS_SECRET_ACCESS_KEY=<SecretAccessKey>.env.secrets— that isrotate_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.secretstoo — but the canonical path is to runrotate_spike_key.shimmediately after, which creates a fresh key + writes.env.secrets. Document this in the README.)
- List the user's access keys:
main()— callcreate_user(),attach_inline_policy(),create_access_key(). Print a summary. Exit 0 on success.- No secrets to a tracked file — the key is printed to stdout
only.
.env.secretsis written byrotate_spike_key.sh, not here. - Idempotency — re-running the script: user exists → skip; policy
exists →
put_user_policyoverwrites (idempotent); key exists → report + do not create (the orchestrator rotates viarotate_spike_key.shif needed).
- Source citations:
PROJECT.mdD-039 (per-run-rotated long-lived key waiver; OIDC deferred to v1.2 — the user + key are deleted in v1.2).PROJECT.mdD-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.jsonT-8.1 (the policy document this script attaches).
- Commit message:
phase: 8, status: plan-as-execute, persona: platform-engineer, task: T-8.4(thenfeat(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.pyexits 0.- The script reads
spike_runner_policy.json(relative to its own__file__dir, not CWD — robust to being run from anywhere). create_access_keyprintsACDL_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 ownsscripts/verify_phase*.shbut not rotation). - Files owned:
scripts/rotate_spike_key.sh(new). - What the file must contain (cite sources):
- Shebang + strict mode —
#!/usr/bin/env bash,set -euo pipefail. - 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)."
- Env var validation — check
ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID+ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEYare set; if not, exit with a clear message. Do NOT echo their values. - Constants —
USER_NAME="acdl-spike-runner",REGION="us-east-1",SECRETS_FILE="$(git rev-parse --show-toplevel)/.env.secrets". - 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
iamclient with the bootstrap creds from env. list_access_keys(UserName=USER_NAME)→ list of access keys.- If ≥1
Activekey: pick the first active one asold_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_FILEvia the python (so it can format it): writeACDL_AWS_ACCESS_KEY_ID=<...>\nACDL_AWS_SECRET_ACCESS_KEY=<...>\ntoSECRETS_FILE, thenos.chmod(SECRETS_FILE, 0o600). - Print the new
AccessKeyId(NOT the secret) to stdout for the log; the secret goes only toSECRETS_FILE. - Exit 0.
- Build a boto3
- 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.
- Optional Gitea secret upload — after the python rotation block,
if
ACDL_GITEA_TOKENis 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_IDwith body{"value":"<new access key id>"}+ headerAuthorization: token ${ACDL_GITEA_TOKEN}.- Same for
ACDL_AWS_SECRET_ACCESS_KEY. - Use
curl(the runner has it) + read the values fromSECRETS_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.secretsis sufficient for the spike's local-run path). - If
ACDL_GITEA_TOKENis 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).
- No secrets to stdout — the script prints the new
AccessKeyId(AKIA..., non-secret-ish — it's the public half) but NOT theSecretAccessKey. The secret goes only toSECRETS_FILE. - .env.secrets format — two lines:
The verify script (T-8.6) + the spike workflows (Phases 09-10)
ACDL_AWS_ACCESS_KEY_ID=<...> ACDL_AWS_SECRET_ACCESS_KEY=<...>sourcethis file or parse it. chmod 600 (owner read/write only). - 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)."
- Shebang + strict mode —
- Source citations:
PROJECT.mdD-039 (per-run-rotated long-lived key waiver).PROJECT.mdD-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.jsongitea block (base_url, owner, repo, api_token_env).
- Commit message:
phase: 8, status: plan-as-execute, persona: platform-engineer, task: T-8.5(thenfeat(P08): author rotate_spike_key.sh (D-039; idempotent; optional Gitea secret upload)) - Self-verify:
bash -n scripts/rotate_spike_key.shexits 0.set -euo pipefailis present.- The script validates
ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID+ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEYare set before proceeding. - The rotation logic uses
python3 - <<'PYEOF'(orpython3 -c) with boto3; noawsCLI dependency. - The script writes
.env.secrets+chmod 600. - The script does NOT echo
ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEYor the newSecretAccessKeyto 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):
- Shebang + strict mode —
#!/usr/bin/env bash,
set -euo pipefail.
- 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."
- 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".
- 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).
- 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.
- 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: ".
- 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".
- 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)".
- 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)".
- Assert .bootstrap_state.json contents —
python3 -c reads it,
asserts account_id == "581513795199", bucket_name matches, etc.
- 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.
- 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):
- 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)."
- 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).
- Run order — numbered steps:
- 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>'
- Create the state backend:
python3 terraform/bootstrap/create_state_backend.py
- 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.
- Rotate the key (creates a fresh key, deactivates the initial
one, writes
.env.secrets):
bash scripts/rotate_spike_key.sh
- Verify:
bash scripts/verify_phase08.sh
- 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).
- Spike scope vs v1.2 boundary — the table (see §"Spike scope vs
v1.2 boundary" below; copy it into the README).
- 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).
- Security constraints — the binding constraints (see §"Security
constraints" below; copy them into the README).
- 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):
python3 terraform/bootstrap/create_state_backend.py — creates the
S3 bucket + DynamoDB table. Assert exit 0. Assert
terraform/bootstrap/.bootstrap_state.json was written.
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.
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.
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.
- 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
()").
- 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)
- 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.
.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.
- 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.
- 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.
- 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).
- 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.
- 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).