Files
acdl/.ciagent/VERIFY.md
T
Jon Chery 6d27dad114 verify(P08): VERIFIED — AWS bootstrap, spike key rotated, D-034 manual
---ci---
project: acdl
phase: 8
milestone: v1.1
status: verify
verdict: VERIFIED
---/ci---
2026-07-21 19:05:37 +00:00

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 — 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:

{
  "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_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_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.