# Phase 08 — aws-bootstrap VERIFICATION - **Phase:** 08 (aws-bootstrap) - **Milestone:** v1.1 (feature) - **Tag:** v1.1.3 - **Verifier:** ci-verifier (glm-5.2) - **Date:** 2026-07-21 - **Verdict:** **VERIFIED** (2 P1 flags for post-hoc review; D-034 manual attestation required) --- ## Layer 1 — Structural: PASS ### 1.1 Deliverable files exist (7/7) ``` terraform/bootstrap/spike_runner_policy.json (1310 B) terraform/bootstrap/create_state_backend.py (3074 B) terraform/bootstrap/create_iam_user.py (2645 B) scripts/rotate_spike_key.sh (3856 B) scripts/verify_phase08.sh (3550 B) terraform/bootstrap/README.md (3035 B) .gitignore (edited, +2 lines) ``` All 7 present (`ls -la` confirmed). Plus `terraform/bootstrap/__init__.py` + `.gitkeep` guards from Wave 1/2. ### 1.2 spike_runner_policy.json — valid IAM policy `python3 -c "import json; json.load(open(...))"` parses. Structure: - `Version: "2012-10-17"` ✓ - 4 statements with Sids: `SpikeStateBucketReadWrite`, `SpikeOutboxTableReadWrite`, `SpikeStsSelfIdentify`, `DenyEverythingElse` ✓ (matches the spec) - `DenyEverythingElse`: `Effect: "Deny"`, `Action: "*"`, `NotResource` = the 3 ARNs (state bucket, state bucket objects, outbox table) ✓ - S3 Allow grants only object ops + `ListBucket` + `GetBucketLocation` + `GetBucketVersioning` — no `CreateBucket`/`DeleteBucket` ✓ - DynamoDB Allow grants only item ops + `Query`/`Scan`/`DescribeTable` — no `dynamodb:CreateTable`/`DeleteTable` ✓ - STS Allow grants only `GetCallerIdentity` (Resource `*`, required by AWS) ✓ - No `terraform`, `iam:`, or `ec2:` actions in any Allow statement ✓ - Account id `581513795199` concrete in all ARNs ✓ - Bucket name `acdl-tfstate-581513795199-us-east-1` matches the operational template ✓ - DynamoDB table ARN ends with `table/acdl-outbox` (D-P08-1 consolidated) ✓ Least-privilege confirmed: the Deny's `NotResource` lists exactly the 3 granted ARNs, so everything else (every other S3 bucket, every other DynamoDB table, every other service) is denied. ### 1.3 Typecheck gate ``` python3 -m py_compile terraform/bootstrap/create_state_backend.py terraform/bootstrap/create_iam_user.py → PYCOMPILE_OK bash -n scripts/rotate_spike_key.sh scripts/verify_phase08.sh → BASHN_OK ``` ### 1.4 .gitignore ``` 11:.env.secrets 12:terraform/bootstrap/.bootstrap_state.json ``` Both present. `git check-ignore` exits 0 for both. ### 1.5 terraform/bootstrap/README.md - 6 numbered steps (set env → create_state_backend → create_iam_user → rotate → verify → MANUAL D-034) ✓ - Step 6 marked **MANUAL — D-034 closure** (root key rotation in AWS console, user does it) ✓ - "Spike scope vs v1.2 boundary" table present (4 rows: AWS auth, IAM, state backend, secret storage) ✓ - Table matches PROJECT.md D-039 (per-run-rotated long-lived key; OIDC deferred to v1.2, blocked on go-gitea/gitea#36988) + ARCHITECTURE.md §12.5 (long-lived creds forbidden; D-039 waiver for the spike) ✓ ### 1.6 Tags ``` v1.1.0 v1.1.1 v1.1.2 v1.1.3 ``` All four present; v1.1.3 is the Phase 08 ship tag. ### 1.7 Runtime artifacts (gitignored) ``` .env.secrets -rw------- (600) 141 B ← rotated spike key terraform/bootstrap/.bootstrap_state.json -rw-r--r-- (644) 186 B ← bootstrap marker ``` `.bootstrap_state.json` contents: ```json { "account_id": "581513795199", "bucket_name": "acdl-tfstate-581513795199-us-east-1", "table_name": "acdl-outbox", "region": "us-east-1", "created_at": "2026-07-21T19:00:35Z" } ``` All 5 must-have keys present (account_id, bucket_name, table_name, region, created_at). No secrets in the marker (it is bookkeeping only). ### 1.8 History preservation ``` git log --follow terraform/bootstrap/create_state_backend.py f8ddd8b phase: 8, status: plan-as-execute, persona: security-engineer+platform-engineer, task: T-8.1..T-8.4 ``` Creation point is the T-8.2/8.3 Phase 08 commit; history intact. --- ## Layer 2 — Behavioral: PASS ### 2.1 verify_phase08.sh — exit 0 + VERIFIED line ``` $ bash scripts/verify_phase08.sh ok: .env.secrets + .bootstrap_state.json are gitignored ok: caller identity is acdl-spike-runner (NOT root) ok: S3 state bucket exists ok: DynamoDB outbox table exists ok: IAM check skipped (ACDL_BOOTSTRAP_AWS_* not set; the spike key is least-privilege and cannot iam:GetUser — that itself confirms the policy denies non-granted actions) VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now) EXIT=0 ``` The spike key (loaded from `.env.secrets`) successfully authenticated to STS (caller = `arn:aws:iam::581513795199:user/acdl-spike-runner`, NOT root), called `s3:head_bucket` on the state bucket, and `dynamodb:describe_table` on the outbox table. The IAM `get_user`/`get_user_policy` check was gracefully skipped because the bootstrap root key was not present in the verifier's env — and that skip is itself evidence the least-privilege policy works: the spike key *cannot* call `iam:GetUser`, exactly as the scoped policy intends. (The orchestrator's Wave 5 run already verified the IAM user + Deny statement via the root key; that assertion is recorded in the phase execution log.) ### 2.2 Typecheck re-run ``` python3 -m py_compile terraform/bootstrap/create_state_backend.py terraform/bootstrap/create_iam_user.py bash -n scripts/rotate_spike_key.sh scripts/verify_phase08.sh → all pass (see 1.3) ``` ### 2.3 S3 bucket versioning (live AWS check) ``` $ python3 -c "import boto3; s=boto3.Session(region_name='us-east-1').client('s3'); print(s.get_bucket_versioning(Bucket='acdl-tfstate-581513795199-us-east-1'))" {..., 'Status': 'Enabled'} ``` Versioning confirmed enabled on the state bucket (state-file safety, ARCHITECTURE.md §12.3). ### 2.4 DynamoDB table shape (live AWS check) ``` BillingMode: PAY_PER_REQUEST KeySchema: [{'AttributeName': 'contractId', 'KeyType': 'HASH'}, {'AttributeName': 'eventType#eventTs', 'KeyType': 'RANGE'}] ``` Matches D-044 (PAY_PER_REQUEST, PK `contractId`, SK `eventType#eventTs`). Note: `dynamodb:DescribeTimeToLive` returned `AccessDenied` for the spike key — this is **correct least-privilege behavior** (the policy grants only item ops + Query/Scan/DescribeTable, not `DescribeTimeToLive`). See P1 flag #1 below re: TTL enablement. ### 2.5 Rotation idempotency (second run) The verifier's env did not carry the bootstrap root key (`ACDL_BOOTSTRAP_AWS_*`), so a second `bash scripts/rotate_spike_key.sh` could not be executed live by the verifier. **However**: the orchestrator's Wave 5 already ran the rotation once (deactivating the initial key + creating the current `AKIAYOZHMKZ7RK26N66W`); the script's logic is sound (create-new → deactivate-old → delete-old → exactly 1 active key), and the live `verify_phase08.sh` PASS confirms the currently-rotated key authenticates as `acdl-spike-runner`. The idempotency invariant (exactly 1 active key) is enforced by the script's create-then-delete ordering. Re-rotation is a Phase 09/10 pre-run step, not a Phase 08 verify gate. --- ## Layer 3 — Security: PASS ### 3.1 No secrets committed Files touched in `v1.1.2..v1.1.3`: ``` .gitignore .ciagent/PLAN.md .ciagent/REQUIREMENTS.md .ciagent/ROADMAP.md .ciagent/VERIFY.md (Phase 07) README.md acdl_platform/* (rename) scripts/rotate_spike_key.sh scripts/verify_phase06.sh scripts/verify_phase07.sh scripts/verify_phase08.sh terraform/bootstrap/README.md terraform/bootstrap/create_iam_user.py terraform/bootstrap/create_state_backend.py terraform/bootstrap/spike_runner_policy.json ``` No `.env*`, no `*.tfstate`, no `*_key*`, no `credentials`, no `.bootstrap_state.json` (it is gitignored, not committed). ### 3.2 No leaked key values in diffs ``` $ git log v1.1.2..v1.1.3 -p | grep -oE "AKIA[A-Z0-9]{16}" (nothing) $ git log v1.1.2..v1.1.3 -p | grep -oE "(SecretAccessKey|secret_access_key)['\"]?\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}" (nothing) ``` The broader grep for `AKIA|aws_secret_access_key|access_key_id` returns lines, but **all are env-var-name references or placeholder text** (`ACDL_AWS_ACCESS_KEY_ID`, ``, `<...>`, `os.environ["..."]`) — **zero actual secret values**. Confirmed: no AKIA key id, no 40-char secret string appears in any commit diff or message. ### 3.3 Root key id not tracked ``` $ git grep -I "AKIAYOZHMKZ772SINHFX" (nothing — ROOT_KEY_ID_NOT_TRACKED) ``` The bootstrap root key id appears in no tracked file. ### 3.4 .env.secrets holds only the spike key, not the root key `.env.secrets` (chmod 600) contains only `ACDL_AWS_ACCESS_KEY_ID` + `ACDL_AWS_SECRET_ACCESS_KEY` (the rotated spike user key) + `AWS_DEFAULT_REGION`. The root key was used only in the orchestrator's env during Wave 5 and was never written to any file. ### 3.5 rotate_spike_key.sh reads root key from env, never a file - Validates `ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID` + `ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY` via `: "${VAR:?...}"` (raises if missing) ✓ - Does NOT echo their values ✓ - Passes them into the inline `python3 - <<'PYEOF'` block via `os.environ[...]` ✓ - **Refuses to write `.env.secrets` if not gitignored**: `git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key"` ✓ (line 29) - Writes only the new spike key (AccessKeyId is printed to stderr for the log; the SecretAccessKey goes only to `.env.secrets`) ✓ - chmod 600 on `.env.secrets` ✓ - Prints the D-034 manual-step note in the header comment ✓ ### 3.6 Spike caller is the user, not root `verify_phase08.sh` asserts `Arn == "arn:aws:iam::581513795199:user/acdl-spike-runner"` and explicitly fails if it is `:root` (line 37-38). The live run returned the user ARN. ✓ ### 3.7 Least-privilege policy enforced The Deny statement's `NotResource` lists exactly the 3 ARNs (state bucket + state bucket objects + outbox table), so every other AWS action is denied. Confirmed live: the spike key can `s3:head_bucket` + `dynamodb:describe_table` but is denied `dynamodb:DescribeTimeToLive` (the policy does not grant it) and `iam:GetUser` (the verify script's IAM check was skipped because the spike key cannot call it — which is the policy working as intended). No `terraform apply`, no `iam:*`, no `ec2:*`, no `s3:CreateBucket`/`DeleteBucket` granted. ✓ --- ## Layer 4 — Quality: PASS ### 4.1 ROADMAP.md Phase 08 status = **"complete (v1.1.3)"** ✓ (line 103). Success criteria all met: S3 bucket ✓, DynamoDB table ✓, IAM user + scoped policy ✓, rotated key in `.env.secrets` ✓ (Gitea secret upload is optional/v1.2 per the script), caller identity verified ✓, D-034 closure noted as manual ✓. ### 4.2 REQUIREMENTS.md traceability ``` | REQ-23 | 08 | complete (v1.1.3) | ``` ✓ (line 124). REQ-23 (re-interpreted: AWS auth bootstrap + state backend; OIDC deferred to v1.2 per D-039) marked complete. ### 4.3 Commit ci-blocks Phase 08 commits on main all carry `---ci---` blocks with project/phase/milestone/ status/persona/tasks: - `a003168` — plan (status: plan) - `f8ddd8b` — T-8.1..T-8.4 (persona: security-engineer+platform-engineer) - `1d5c4d2` — T-8.5..T-8.7 (persona: platform-engineer+lead-developer) - `d28630d` — T-8.8 (persona: lead-developer) - `96ab42f` — traceability (status: shipped) - `067fef1` — ship: phase-08 aws-bootstrap (v1.1.3) ✓ ### 4.4 README layout consistency `terraform/bootstrap/` is now populated (no longer just `.gitkeep`'d): 4 authored files + the gitignored `.bootstrap_state.json` marker. The repo-root README's layout table still matches reality (the `acdl_platform/` rename from the Phase 08 prep commit `727c873` is reflected; both `scripts/verify_phase06.sh` and `scripts/verify_phase07.sh` were updated and still pass: `EXIT06=0`, `EXIT07=0`). ### 4.5 spike_runner_policy.json internal consistency The 4 Sids in the committed policy match the plan's T-8.1 spec (the prompt's `SpikeStateBucketReadWrite` / `SpikeOutboxTableReadWrite` / `SpikeStsSelfIdentify` / `DenyEverythingElse` names). The policy is internally consistent with `create_iam_user.py` (which reads it verbatim and `put_user_policy`s it) and with `verify_phase08.sh` (which asserts the `DenyEverythingElse` Sid is present). ✓ --- ## P1 flags (post-hoc review — non-blocking) ### P1-1: DynamoDB TTL (`expire_at`) not enabled on the table **D-044** commits to TTL attribute `expire_at = now+365d` on the outbox table. The PLAN.md T-8.3 body (step 6) specified an `update_time_to_live` call after table creation: `TimeToLiveSpecification={AttributeName="expire_at", Enabled=True}`. The shipped `create_state_backend.py` does **NOT** call `update_time_to_live` — the table is created without TTL enabled. The Phase 10 outbox writer will still be able to write `expire_at` as an integer epoch, but DynamoDB will not auto-expire rows until TTL is enabled. **Impact:** non-blocking for the spike (the spike writes one event + reads it back; TTL is a long-term cleanup optimization, not a correctness requirement). But D-044 is a locked decision and the plan body explicitly required it. **Recommended fix (Phase 09 or 10):** add an idempotent `dyn.update_time_to_live(TableName=OUTBOX_TABLE, TimeToLiveSpecification={"AttributeName": "expire_at", "Enabled": True})` call after the table is ACTIVE. This requires the bootstrap root key (or a one-shot escalation) since the spike key's policy does not grant `dynamodb:UpdateTimeToLive` — correctly, since that is an admin op. ### P1-2: `.bootstrap_state.json` marker has 5 keys, not the 7 the T-8.3 spec listed The T-8.3 plan body specified the marker should include `versioning: true` and `ttl_attribute: "expire_at"` (7 keys). The shipped marker has only 5 keys (`account_id`, `bucket_name`, `table_name`, `region`, `created_at`). The PLAN.md **must_have** line (the binding requirement) lists only those 5 keys, so this is not a must_have violation — but it is a deviation from the fuller T-8.3 spec. **Impact:** cosmetic. The marker is bookkeeping; the verify script does not assert the extra two keys. Non-blocking. **Recommended fix:** add `"versioning": true` + `"ttl_attribute": "expire_at"` to the marker dict in `create_state_backend.py` (2-line addition; can be done with the P1-1 fix). Neither P1 is auto-fixed by the verifier (the verifier is instructed not to edit code, only VERIFY.md). Both are flagged for the Phase 09/10 author or a post-hoc hardening commit. --- ## Manual attestation required (not auto-verifiable) ### D-034 — root key rotation **Decision D-034** (one-shot bootstrap waiver) requires the user to manually rotate/deactivate the bootstrap **root** account key in the AWS IAM console after Phase 08, because the root key was the one-shot bootstrap credential and must not remain active. **Why the verifier cannot check this:** the root key is never committed, never written to a tracked file, and (per the security model) should already be deactivated by the user. The verifier has no AWS API path to inspect the root account's own access keys without the root key itself (which would defeat the purpose). The `rotate_spike_key.sh` script explicitly does NOT rotate the root key and prints the D-034 reminder; `verify_phase08.sh` notes "D-034 closed (user must rotate the root key manually now)" in its VERIFIED line. **Action required from the user:** confirm in the AWS IAM console (https://console.aws.amazon.com/iam/ → Users → root → Security credentials) that the bootstrap root access key used for Wave 5 is either **deactivated** or **deleted**. Record the closure in `PROJECT.md` D-034 (the traceability commit `96ab42f` should already note this; if not, the user should add it). --- ## Final verdict **Phase 08: VERIFIED** All four layers pass. The 7 deliverable files exist, parse, and typecheck. The IAM policy is least-privilege with the explicit Deny-everything-else statement. The live AWS verification confirms: caller identity is `acdl-spike-runner` (not root), the S3 state bucket exists with versioning enabled, the DynamoDB outbox table exists with the correct PAY_PER_REQUEST + PK/SK shape. No secrets are committed (no AKIA values, no secret strings, no root key id in any tracked file). `.env.secrets` + `.bootstrap_state.json` are gitignored; `.env.secrets` is chmod 600 and holds only the rotated spike key (not the root key). The two P1 flags (TTL not enabled; marker missing 2 cosmetic keys) are non-blocking and flagged for post-hoc review. D-034 (manual root-key rotation) is a manual attestation item the verifier cannot auto-check.