PAYCARGO · AI-SANDBOX · PROCESS

1Apply Bootstrap

The first Terraform apply in the AI-sandbox account. Creates the state backend, the CI deploy identity, and the account-wide permission boundary.
Block: 1 of N · Operator: the platform architect (one-time, manual, from laptop) · Cost: ~$1/month · Permanence: Permanent — tear-down would require rebuilding everything downstream
Completed

Executed: All 13 resources created in AI-sandbox account 959228203854. All six verification checks passed. CI deploy role trust policy correctly cased as PayCargoDevOps/ai-sandbox.

One gotcha hit and resolved: Gotcha B from Section 8 — the default S3 bucket name ai-sandbox-tfstate was globally taken. Suffixed with the account ID to ai-sandbox-tfstate-959228203854. Documented in Section 12.

Visual companion: Step.1.diagram.html

Executive Summary

Deployed the foundational AWS resources that every subsequent infrastructure module will depend on.

Three structural properties get established here that survive for the entire life of the AI-sandbox account. First, every infrastructure change is now tracked in version control (S3 + DynamoDB state backend), so changes are peer-reviewable and recoverable. Second, GitHub Actions can deploy to AWS without any long-lived secrets (OIDC federation scoped to one specific repository). Third, a permission boundary policy now blocks any role from creating IAM users, disabling audit logging, or deleting the state encryption key, regardless of what permissions someone tries to grant. This is the single window in the platform's lifetime where a human runs Terraform from a laptop; from here forward, CI handles applies.

StatusCompleted
Cost~$1/month
EffortPlan: ~3 weeks (substrate doctrine, module design, permission boundary scoping)
Apply: ~10 min
Review: ~30 min
RiskLow

If skipped: No remote Terraform state (every engineer has divergent local state, no shared truth). No CI deploy path (every change requires laptop credentials). No permission boundary (any future role can elevate to create IAM users or disable logging). The next ten years of infrastructure either require these or replicate them ad-hoc.

Executed configuration

AI-sandbox AWS account
959228203854
State bucket (overridden from default)
ai-sandbox-tfstate-959228203854
State KMS CMK ID
29459c6e-6294-42f9-bfa0-a63f02f5a86e (alias/ai-sandbox-tfstate)
State KMS CMK ARN
arn:aws:kms:us-east-1:959228203854:key/29459c6e-6294-42f9-bfa0-a63f02f5a86e
State lock table
ai-sandbox-tfstate-lock (PITR ENABLED)
OIDC provider ARN
arn:aws:iam::959228203854:oidc-provider/token.actions.githubusercontent.com
Deploy role ARN
arn:aws:iam::959228203854:role/ai-sandbox-github-actions-deploy
Permission boundary ARN
arn:aws:iam::959228203854:policy/ai-sandbox-permission-boundary
OIDC trust scope (verified)
repo:PayCargoDevOps/ai-sandbox:ref:refs/heads/main
repo:PayCargoDevOps/ai-sandbox:ref:refs/tags/*
repo:PayCargoDevOps/ai-sandbox:pull_request

1. Why this step exists

Bootstrap is the first Terraform apply in the AI-sandbox account. It does the smallest possible set of work that has to exist before any other Terraform module can run safely. Three properties make a module "bootstrap":

  1. It is run by a human — no CI exists yet to run it (CI requires bootstrap to have run, so it cannot bootstrap itself).
  2. It uses local state — remote state requires S3 + DynamoDB + KMS to exist, and bootstrap is the thing that creates them.
  3. It creates the substrate for everything else — the state backend that downstream modules write to, the CI identity that downstream modules are applied through, and the permission boundary that constrains every IAM role created from this point forward.

The one-line summary

Bootstrap creates the smallest set of foundational resources that lets every other module use remote state and CI-based applies. It runs once, by a human, from a laptop, with local Terraform state, and is essentially never re-applied (except for incremental changes to its own resources).

2. What this step creates

Six classes of resources, all named with the ai-sandbox-* prefix so they are unambiguous in CloudTrail and the AWS console.

ResourceWhat it is and why
KMS CMK
alias/ai-sandbox-tfstate
Customer-managed key encrypting the state bucket and the lock table. CMK (not AWS-managed) so the key policy and rotation cadence are controlled by this account. Costs $1/month; this is the only standing charge in bootstrap.
S3 bucket
ai-sandbox-tfstate
Holds Terraform state for every downstream module. Versioned (recovery), public access blocked, TLS-only via bucket policy, encrypted with the CMK above, lifecycle rule moves non-current versions to Glacier Deep Archive after 90 days. Storage cost is negligible (state files are well under 1 MB).
DynamoDB table
ai-sandbox-tfstate-lock
Provides state locking so concurrent terraform applys cannot race. On-demand capacity (no idle cost), PITR enabled (point-in-time recovery for the rare case of lock corruption), deletion-protected.
IAM OIDC provider
token.actions.githubusercontent.com
Federates GitHub Actions OIDC tokens into AWS IAM. One per AWS account, not per repo. Free.
IAM role
ai-sandbox-github-actions-deploy
The role CI assumes via OIDC. Trust policy scoped to exactly one repo (PayCargoDevOps/ai-sandbox) on main, tags, or PR refs. No other repo, branch, or org can assume it. Free.
IAM managed policy
ai-sandbox-permission-boundary
Attached to every IAM role created by every downstream module. Caps what any of those roles can do, even if the role's inline policy is more permissive. Detailed breakdown in Section 4. Free.

All resources are tagged with Project=ai-sandbox, Owner=platform-team, ManagedBy=terraform, Module=bootstrap.

3. Why bootstrap is on local state (the chicken-and-egg)

Every other Terraform module in this project uses remote state — the S3 bucket and DynamoDB lock table that bootstrap creates. Bootstrap itself cannot, because those resources do not yet exist when bootstrap runs. So bootstrap is the one and only module that lives on local state, with its terraform.tfstate file written to the operator's laptop in substrate/bootstrap/.terraform/.

Implications of local state

Why we do not migrate bootstrap to remote state after it succeeds

It would be technically possible to migrate bootstrap to use its own newly-created state backend after first apply. We deliberately do not. Keeping bootstrap on local state preserves the chicken-and-egg solvability: a future operator who needs to recreate bootstrap from scratch (lost account, accidental destroy, audit recovery) does not need a working remote backend to run the first apply. The cost is mild operational quirkiness; the benefit is permanent recoverability.

4. What this step secures (the permission boundary in detail)

The permission boundary is the most consequential resource bootstrap creates. It is a managed IAM policy that every downstream IAM role attaches via the permissions_boundary argument. The boundary acts as a ceiling on what any role can do, regardless of how permissive its inline or attached policies are.

What the boundary blocks (defense in depth, even against your own future mistakes)

ActionWhy blocked
iam:CreateUser, iam:CreateLoginProfile, iam:CreateAccessKey No IAM users, no console logins, no long-lived access keys may be created in this account ever again. The bootstrap-created identity (your SSO session) is the only path in; future identities go through Identity Center.
iam:DeleteOpenIDConnectProvider, iam:UpdateOpenIDConnectProviderThumbprint The GitHub OIDC provider cannot be deleted or tampered with by any role except the human operator. Prevents an attacker who compromises a downstream role from breaking the CI deploy path.
kms:ScheduleKeyDeletion, kms:DisableKey on the state KMS key The state KMS key cannot be scheduled for deletion or disabled by any downstream role. Losing it would brick every Terraform module that uses remote state.
cloudtrail:StopLogging, cloudtrail:DeleteTrail, guardduty:DeleteDetector, config:DeleteConfigRule Audit and threat-detection services cannot be disabled by any downstream role. An attacker who compromises a workload role cannot disable the very logging that would catch them.
iam:PutRolePolicy for self-elevation patterns (creating roles without the boundary, etc.) A role cannot create a new role without also attaching the same boundary. Prevents the classic boundary-bypass-by-role-creation attack.

What the boundary deliberately does not block

Why this is the right place for the boundary

Boundaries enforced at the role level via the permissions_boundary attribute are the only AWS-native way to make policy-level restrictions structural. SCPs (Service Control Policies) at the AWS Organizations level are an alternative, but require management-account access to change and are coarser-grained. The bootstrap permission boundary lives in the AI-sandbox account itself, gets versioned with the Terraform code that defines it, and applies to exactly the roles we intend.

5. Cost & permanence

Cost

ItemMonthly
KMS CMK (state encryption)$1.00
S3 bucket (state, < 1 MB)< $0.01
DynamoDB on-demand (locking, ~10 writes/month at quiet steady state)< $0.10
OIDC providerfree
IAM role + permission boundary policyfree
Total~$1.00/month

The KMS CMK is the only standing charge. It exists to give this account control over key rotation and key policy, rather than relying on AWS-managed keys whose lifecycle is opaque.

Permanence

Permanent. Bootstrap resources are intended to be permanent for the life of the AWS account. Tearing them down would invalidate every Terraform state file in S3 (breaking every downstream module), remove the only CI deploy path, and remove the permission boundary every role depends on. Removal would be operationally equivalent to deleting the account.

Modifying bootstrap resources is rare but supported (rotate state KMS key, add a new repo to the OIDC trust, tighten a clause in the permission boundary). These changes are made by re-applying bootstrap with the same local-state setup.

6. How to apply

Preconditions (must be true before apply)

Commands

cd substrate/bootstrap

# Confirm identity one more time
aws sts get-caller-identity   # Account must be 959228203854

# Initialize Terraform (downloads AWS provider, uses local state)
terraform init

# Plan the apply. Output saved to tfplan so the same plan is what gets applied.
terraform plan -out=tfplan

# READ THE PLAN OUTPUT CAREFULLY before applying.
# Expected: ~8 resources, every one with a "+ create" marker.
# Anything other than creates (~ update, - destroy) means something is off —
# do not apply, paste the plan output to the team for diagnosis.

# If plan is clean, apply.
terraform apply tfplan

# Capture outputs (needed for Block 2, which will wire the remote backend).
terraform output > /tmp/bootstrap-outputs.txt
cat /tmp/bootstrap-outputs.txt

Apply takes 3-4 minutes. Most of the time is S3 bucket creation and the eventual-consistency wait for the IAM role to become visible.

7. Verification

After terraform apply reports success, verify each created resource is functional. All six commands should succeed without error.

# 1. State bucket exists, versioning ON, encryption ON with our CMK
aws s3api get-bucket-versioning --bucket ai-sandbox-tfstate
aws s3api get-bucket-encryption --bucket ai-sandbox-tfstate

# 2. Lock table exists with point-in-time recovery
aws dynamodb describe-continuous-backups --table-name ai-sandbox-tfstate-lock \
  | jq '.ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus'
# Expected output: "ENABLED"

# 3. OIDC provider exists
aws iam list-open-id-connect-providers
# Expected output includes: "...:oidc-provider/token.actions.githubusercontent.com"

# 4. Deploy role exists and trust policy is scoped to PayCargoDevOps/ai-sandbox ONLY
aws iam get-role --role-name ai-sandbox-github-actions-deploy \
  | jq -r '.Role.AssumeRolePolicyDocument.Statement[0].Condition.StringLike."token.actions.githubusercontent.com:sub"'
# Expected output (three lines):
#   repo:PayCargoDevOps/ai-sandbox:ref:refs/heads/main
#   repo:PayCargoDevOps/ai-sandbox:ref:refs/tags/*
#   repo:PayCargoDevOps/ai-sandbox:pull_request
# The casing MUST match "PayCargoDevOps" (not lowercase) or CI will fail later
# with AccessDenied on AssumeRoleWithWebIdentity.

# 5. Permission boundary exists
aws iam list-policies --scope Local \
  --query 'Policies[?PolicyName==`ai-sandbox-permission-boundary`].Arn' \
  --output text
# Expected output: a single ARN.

# 6. KMS CMK exists with the expected alias
aws kms list-aliases --query 'Aliases[?AliasName==`alias/ai-sandbox-tfstate`].TargetKeyId' \
  --output text
# Expected output: a single key ID.

8. How to view the work in AWS Console

Bootstrap creates six resource classes. Each is visible in the AWS Console at a specific place. Going through these in order is the best way to build a mental model of what's live.

ResourceWhere to lookWhat confirms success
State KMS CMK KMS → Customer managed keys → click ai-sandbox-tfstate Alias is alias/ai-sandbox-tfstate. Key policy tab shows your SSO admin role and the AWS services (S3, DDB) as principals. Key rotation is enabled (annual). Status is Enabled.
State S3 bucket S3 → Buckets → click ai-sandbox-tfstate-959228203854 Versioning: Enabled. Encryption: SSE-KMS with the state CMK. Block public access: all four blocks on. Lifecycle: one rule transitioning non-current versions to Glacier Deep Archive after 90 days. TLS-only policy applied (Permissions tab).
State lock table DynamoDB → Tables → click ai-sandbox-tfstate-lock Partition key: LockID (String). Capacity mode: On-demand. Backups tab → Point-in-time recovery: Enabled. Deletion protection: Enabled.
GitHub OIDC provider IAM → Identity providers → click token.actions.githubusercontent.com Provider URL: token.actions.githubusercontent.com. Audience: sts.amazonaws.com. Thumbprints listed. This is the federation root for GitHub Actions.
CI deploy role IAM → Roles → search ai-sandbox-github-actions-deploy Trust relationships tab: principal is the OIDC provider above, condition limits to repo:PayCargoDevOps/ai-sandbox: on main, tags, pull_request. Permissions: admin-grade for downstream applies. Boundary tab: ai-sandbox-permission-boundary attached.
Permission boundary IAM → Policies → filter "Customer managed" → click ai-sandbox-permission-boundary → JSON tab The Deny statements are the doctrine. Read them: blocks IAM user creation, OIDC provider deletion, state KMS deletion, CloudTrail/GuardDuty/Config disabling. Sit with this for a minute — this is what makes "no IAM users ever" structural.

Worth a side trip: the Resource map

None of these show up in a single visual yet (they're not network resources). The visual story of bootstrap is told via the relationships in each individual page above. Step 3's "Resource map" (VPC topology) is the first place where the visual comes into its own.

Why look in all six places, not just verify via CLI

The CLI checks confirm "the resource exists and looks right." The Console pages show you the surrounding context — the key usage history, the IAM relationships, the bucket policy in full, the trust policy formatted readably. When something goes wrong later (an IAM puzzle, a state lock issue, a CI workflow not assuming the role), the Console pages are where you'll be debugging. Familiarity now is debugging speed later.

9. Anticipated gotchas

These are the failure modes most likely to surface during apply. Listed in order of frequency.

Gotcha A — OIDC provider already exists in the account

The GitHub OIDC provider is account-wide; only one can exist per account. If a sibling repo or earlier work created it, terraform apply will fail with EntityAlreadyExists.

Fix: import the existing provider into Terraform state and re-apply.

terraform import aws_iam_openid_connect_provider.github \
  arn:aws:iam::959228203854:oidc-provider/token.actions.githubusercontent.com
terraform apply tfplan

The import is non-destructive; it just tells Terraform "you already own this resource."

Gotcha B — S3 bucket name conflict

S3 bucket names are global across all of AWS. If ai-sandbox-tfstate is already taken by an unrelated AWS account anywhere in the world, apply fails with BucketAlreadyExists.

Fix: override the bucket name in terraform.tfvars by appending the account ID (which is unique to us):

state_bucket_name = "ai-sandbox-tfstate-959228203854"

Then re-run terraform plan and terraform apply.

Gotcha C — Wrong account

If aws sts get-caller-identity returns an account other than 959228203854, the apply will create bootstrap resources in the wrong account. Catastrophic if not caught before apply, because the resources are named generically and would pollute the wrong account.

Fix: stop. Re-export AWS_PROFILE=ai-sandbox and re-confirm with aws sts get-caller-identity. If the SSO token has expired, run aws sso login --profile ai-sandbox first.

Gotcha D — SSO token expires mid-apply

SSO tokens last 12 hours by default. If apply is run near the expiry boundary, an in-progress operation can fail partway through with ExpiredToken. Terraform's state file may then contain partially-applied resources.

Fix: re-run aws sso login --profile ai-sandbox to refresh the token, then run terraform apply tfplan again. Terraform is idempotent; it will resume from where it left off.

Gotcha E — OIDC sub claim casing mismatch

The most subtle gotcha. If github_org is left at its lowercase default (paycargodevops) instead of the actual repo casing (PayCargoDevOps), apply succeeds, but later CI workflows fail with AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity. The trust policy and the OIDC sub claim must match exactly, including case.

Fix: already addressed in terraform.tfvars via github_org = "PayCargoDevOps". Verify by running command #4 in Section 7 after apply.

9. Go / no-go gates

Pass all four gates before running terraform apply tfplan.

1
Identity gate

aws sts get-caller-identity returns Account: 959228203854 and an Arn starting with arn:aws:sts::959228203854:assumed-role/AWSReservedSSO_AdministratorAccess_. Anything else is a no-go.

2
Plan gate

terraform plan -out=tfplan output shows ~8 resources, every one with + create. Any line with ~ update or - destroy is a no-go — on a fresh account those should not appear, and their presence indicates state contamination or a name collision.

3
Tfvars gate

terraform.tfvars contains github_org = "PayCargoDevOps" (case-sensitive). Verify with grep github_org substrate/bootstrap/terraform.tfvars. If the file is missing or the casing is wrong, the deploy role trust policy will not match what GitHub Actions sends, and CI will be broken from day one.

4
Repo gate

You have push access to git@github.com:PayCargoDevOps/ai-sandbox.git, and the repo is the canonical home of the code being applied. After bootstrap succeeds, the Terraform code must be pushed to that repo so CI can pick it up. If the repo does not exist or you lack push access, bootstrap will still succeed, but the deploy role's purpose is undefined.

If all four gates pass, run terraform apply tfplan. If any gate fails, address it before proceeding.

10. Doctrine implications

Bootstrap is where the platform's doctrine becomes structural rather than aspirational. Three principles get enforced here in ways that future steps cannot undo.

The permission boundary is forever

From the moment bootstrap's permission boundary policy is created and attached to the first downstream role, every IAM role in the account inherits its constraints. The boundary is the single enforcement point for "no IAM users may be created in this account," "the OIDC provider cannot be deleted by an automation role," "logging cannot be disabled by an automation role." These properties become structural facts, not policy memos.

CI replaces laptop applies on day one

After bootstrap succeeds, the GitHub Actions deploy role exists. From this point forward, the operator's laptop should never run terraform apply against the AI-sandbox account except for re-applying bootstrap itself or in genuine break-glass situations. Every downstream module is applied by CI, which means every apply is a git commit that anyone can review, every apply has an audit trail in the GitHub workflow log, and no apply requires AWS credentials on a laptop.

The chicken-and-egg is permanently solvable

Keeping bootstrap on local state means a future operator (years from now, post-laptop-loss, post-audit-recovery, post-account-rebuild) can always reconstruct bootstrap from the Terraform code in the repo plus AWS admin credentials, with no dependency on prior state existing. This is the property that makes the platform's "everything in code" claim genuine rather than fragile.

The takeaway

Bootstrap looks like a one-time housekeeping apply. It is actually the single moment the doctrine becomes enforced rather than aspirational. The permission boundary, the repo-scoped OIDC trust, and the local-state preservation are the three structural commitments that downstream work depends on for the life of the platform.

12. What actually happened during apply

Apply executed in two passes due to one anticipated gotcha. Documented here so the next engineer learns from the actual run, not just the theoretical plan.

Pass 1 — partial success (7 of 13)

First terraform apply tfplan created the following resources in order:

Then failed on aws_s3_bucket.tfstate with:

Error: creating S3 Bucket (ai-sandbox-tfstate):
  StatusCode: 409, BucketAlreadyExists

This is Gotcha B from Section 8 — S3 bucket names are global, and ai-sandbox-tfstate was already owned by another AWS account somewhere in the world.

Fix — suffix the bucket name with the account ID

Added one line to terraform.tfvars:

state_bucket_name = "ai-sandbox-tfstate-959228203854"

Account IDs are unique across all of AWS, so this guarantees the bucket name is also unique.

Pass 2 — clean completion (6 of 6 remaining)

Re-ran terraform plan; output was Plan: 6 to add, 0 to change, 0 to destroy — Terraform recognized the 7 resources from Pass 1 as already existing and only re-planned the bucket and its 5 configuration sub-resources. Apply finished in ~60 seconds; all 6 resources created cleanly.

Why this was a clean recovery

Terraform's local state file (in substrate/bootstrap/.terraform/) captured the 7 Pass-1 resources accurately. There was no manual cleanup, no destroyed resources, no orphans. The fix was a one-line tfvars change followed by re-running plan and apply. This is exactly the failure-mode behavior the gotcha section in this doc was written to predict.

11. What comes next

After bootstrap apply succeeds and all six verification commands return as expected:

  1. Push to the repo. Push the Terraform code to PayCargoDevOps/ai-sandbox so it lives in the canonical place. CI is now wired (deploy role exists, OIDC trust scoped to this repo) but no workflow is in place yet to use it; Block 2 (Identity) will be applied locally for now, with CI workflows landing in a later block.
  2. Migrate the environments composition to remote state. The output backend_config_block from bootstrap is the snippet that drops into substrate/environments/ai-sandbox/backend.tf. After that, terraform init -migrate-state in the composition directory wires it to remote state.
  3. Proceed to Block 2: Identity. This deploys three trust-zone KMS CMKs (agent, lockbox, audit) and the tier IAM role templates (sor-reader, internal). Cost: $3/month. Risk: none — no compute, no network. See Step.2.html when generated.

If bootstrap fails or verification surfaces an issue, do not proceed to Block 2. Diagnose and either re-apply bootstrap or, in the worst case, destroy and re-apply (only the KMS CMK has a deletion delay; everything else is recreatable in minutes).