---ci--- project: acdl phase: 8 milestone: v1.1 status: verify verdict: VERIFIED ---/ci---
16 KiB
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— noCreateBucket/DeleteBucket✓ - DynamoDB Allow grants only item ops +
Query/Scan/DescribeTable— nodynamodb:CreateTable/DeleteTable✓ - STS Allow grants only
GetCallerIdentity(Resource*, required by AWS) ✓ - No
terraform,iam:, orec2:actions in any Allow statement ✓ - Account id
581513795199concrete in all ARNs ✓ - Bucket name
acdl-tfstate-581513795199-us-east-1matches 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:
{
"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,
<root secret>, <...>, 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_KEYvia: "${VAR:?...}"(raises if missing) ✓ - Does NOT echo their values ✓
- Passes them into the inline
python3 - <<'PYEOF'block viaos.environ[...]✓ - Refuses to write
.env.secretsif 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_policys 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.