Files
acdl/.ciagent/RESEARCH.md
T
Jon Chery ad0e0378da docs(P56a): research findings for v1.11 RESTART
RESEARCH stage. Verified the technical assumptions behind the 13-phase
v1.11 restart plan against the live codebase (branched off v1.10.2).

Findings:
- Adapter monolith audit: adapters/terraform/adapter.py is 918 lines
  with 3 constant tables (TYPE_MAP/INPUT_MAP/OUTPUT_MAP) that duplicate
  what interface.json already declares, plus 39 type-specific branches
  across 18 stack types carrying nested HCL blocks + hardcoded defaults
  (CIDR, assume_role_policy JSON, ECR/logs inline policy, Fargate
  requires_compatibilities, assign_public_ip, listener/target ports,
  security group emission). STANDARDS.md §8 blessed this drift as the
  intended design — the standards doc itself must be rewritten (P56a).
- State-key root cause of the 4-VPC bug: adapter.py:664,676 emits
  spike/{stack_name}/terraform.tfstate where stack_name = contract.id;
  all 5 microservice contracts share id 'msvc' but differ in
  environment (dev/qa/prod/dr); the state key does NOT include the
  environment, so all 4 env contracts collide on spike/msvc/terraform.tfstate.
  Combined with verify_deploy_microservice.py running terraform init
  -reconfigure in a fresh temp dir each time, each run created a fresh
  VPC. Two root causes: (1) per-contract state keys with no VPC sharing,
  (2) non-deterministic state keys across environments. D-105 + D-106 +
  D-101 correct all three.
- Per-module terraform module design: documented the
  versions/variables/locals/main/outputs.tf layout for s3, vpc, ecs-service
  and how the stateless adapter assembles them via registry.json →
  terraform_dir → module-instantiation blocks + ref wiring.
- Existing pipeline architecture: run_platform.sh line 287 runs terraform
  plan only (never apply/destroy); the --apply/--destroy lifecycle modes
  must be ADDED (P57). Byte-identical Gitea+GitHub convention documented.

PERSONAS.md updated for v1.11:
- Deactivated lambda-engineer, platform-engineer, security-engineer,
  frontend-engineer (no per-module Python this milestone).
- Reactivated data-engineer (owns terraform/ + per-module terraform
  subdirs — the heaviest v1.11 work).
- Kept backend-engineer (adapter/resolver), general (pipelines/workflows).
- Territory enforcement: warn (co-authoring expected on adapter +
  run_platform.sh boundary).
- Domain priority: data → backend → general.

6 assumptions logged (A-1.1..A-5.1), all >= 0.6 confidence, none
escalated.

---ci---
project: acdl
phase: 0
milestone: v1.11
status: research
---/ci---
2026-07-28 16:07:57 +00:00

33 KiB
Raw Blame History

ACDL — v1.11 RESTART Research Findings

Phase: research (pre-Phase 56). Milestone: v1.11 (RESTART). Status: research. Researcher: ci-researcher. Autonomy: full (CLARIFY auto-resolved; all binding decisions D-097..D-107 are committed in the CLARIFY stage). Branch: milestone/v1.11-restart (branched off tag v1.10.2, per D-097). Sources: ACDL codebase (v1.10.2 tree) + git history (failed first attempt on phase/56-iam-re-bootstrap + phase/57-live-deploy-microservice) + the CLARIFY commit (80b7286).

This file overwrites the prior v1.1 research artifact. v1.11 is a fresh milestone; the v1.1 research (Gitea OIDC, Checkov, IR shape, outbox) is historical and preserved in git history. This file documents the technical findings that ground the v1.11 restart plan.


Background — why v1.11 is a restart

v1.11 is a restart, not a continuation. The first attempt (phase/56 + phase/57, abandoned per D-097) made five defects worse, not better. The restart branches off the clean v1.10.2 tag and corrects three structural defects that the CLARIFY stage locked as binding decisions:

  1. Stateless adapter (D-098, D-099, D-100). The current adapter is a 750-line monolith with 3 constant tables and 39 type-specific branches that duplicate what interface.json already declares and hardcode defaults that belong in the module. v1.11 makes it a ~80-line stateless assembler; each L1 ships a real terraform/ module dir that owns its resource shape, nested blocks, and defaults.
  2. Terraform owns lifecycle (D-101). The first attempt added a Python script (verify_deploy_microservice.py) that ran terraform init -reconfigure in a fresh temp dir each time, which contributed to the 4-VPC bug. v1.11 deletes that script; run_platform.sh gains --apply and --destroy modes; Python never runs terraform.
  3. Pipeline-driven testing (D-102, D-103, D-104). No per-module Python/pytest. A modules-lifecycle pipeline matrix-runs each L1 module's examples/{simple,complex}.yml contracts through apply→modify→destroy against live AWS. The "test" = the pipeline cell going green.

FINDING 1 — Adapter monolith audit

1.1 The three constant tables

adapters/terraform/adapter.py (750 lines on the v1.10.2 tree) is built around three constant tables:

Table Line What it encodes Entries
TYPE_MAP 26 Stack type (aws:<service>:<kind>) → Terraform resource type (aws_s3_bucket, aws_vpc, …). 19
INPUT_MAP 51 Stack input name → Terraform arg name, per stack type. Only non-identity mappings are listed; an input not present uses the stack name as the Terraform arg (identity). 19 (one per stack type)
OUTPUT_MAP 75 Stack output name → Terraform attribute name, per stack type. Only non-identity mappings. 19 (one per stack type)

Why they duplicate interface.json. Each L1 module already declares its inputs, outputs, and stack type in interface.json (engine-agnostic). The three tables are the engine binding — the Terraform-specific name mappings that interface.json deliberately omits (it is engine-agnostic per ARCHITECTURE.md §12). The duplication is therefore intentional in the original design: the adapter was meant to be a thin translator that holds the engine binding in three tables, and the L1 holds the engine-agnostic content.

The drift. What was not intended is that the tables grew into 39 type-specific branches (§1.2) that hardcode resource shapes, nested HCL blocks, and defaults (§1.3) — content that belongs in the module, not the adapter. The adapter stopped being a thin translator and became a per-resource-type code generator. D-098 corrects this: the engine binding moves into a per-module terraform/ subdir (the real Terraform module), and the adapter becomes a stateless assembler that emits module "x" { source = "..." ... } blocks. The three tables are deleted.

1.2 The 39 type-specific branches across 18 stack types

_emit_resource (line 156) is a generic loop that, for each input, looks up the Terraform arg in INPUT_MAP, renders the value, and appends arg = value. But 18 of the 19 stack types have a specialized branch inside _emit_resource that runs after the generic loop and emits nested HCL blocks, hardcoded defaults, or resource-specific wiring. The count of 39 branches is the sum of the per-type specializations (some types have 23 branches). The full inventory:

# Stack type Terraform type Specialized logic (what the branch does)
1 aws:s3:bucket aws_s3_bucket versioning {} block (default true); server_side_encryption_configuration {} block (SSE-KMS, CMK ref or managed-key fallback with stderr warning); kms_key_arn is not a bare arg — emitted as the SSE block.
2 aws:ec2:vpc aws_vpc tags { Name = ... } from the name input; hardcoded cidr_block = "10.0.0.0/16" default when the L2 doesn't supply a CIDR (line 288).
3 aws:ec2:subnet aws_subnet vpc_id = aws_vpc.vpc-vpc.id hardcoded ref when not in inputs; hardcoded cidr_block = "10.0.1.0/24" default (line 296); tags { Name = ... }.
4 aws:ec2:routetable aws_route_table vpc_id = aws_vpc.vpc-vpc.id hardcoded ref; route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.vpc-igw.id } hardcoded default route; tags { Name = "<name>-rt" }.
5 aws:ecs:cluster aws_ecs_cluster Hardcoded name = "acdl-microservice" default when not in inputs (line 300).
6 aws:ecs:task_definition aws_ecs_task_definition _container_definitions() helper: jsonencodes image/port/env into a container_definitions block; hardcoded family = "app" default (line 279).
7 aws:ecs:service aws_ecs_service network_configuration {} block (subnets + security_groups wrapped in list brackets); load_balancer {} block from lb_target_group_arn with hardcoded container_name = "app" + container_port = 8080; hardcoded desired_count = 1, launch_type = "FARGATE", task_definition = aws_ecs_task_definition.service-task-definition.arn, name = "acdl-microservice".
8 aws:iam:role aws_iam_role managed_policy_arns = [...] from comma-separated string; hardcoded ECS task execution assume_role_policy JSON when not supplied (line 326331); hardcoded name = "acdl-microservice-role" default.
9 aws:elbv2:loadbalancer aws_lb subnets/security_group wrapped in list brackets; hardcoded load_balancer_type = "application" default.
10 aws:elbv2:listener aws_lb_listener default_action { type = "forward" target_group_arn = aws_lb_target_group.alb-targetgroup.arn } hardcoded; load_balancer_arn = aws_lb.alb-loadbalancer.id hardcoded ref.
11 aws:elbv2:targetgroup aws_lb_target_group Hardcoded target_type = "ip", vpc_id = aws_vpc.vpc-vpc.id, protocol = "HTTP", port = 8080.
12 aws:ecr:repository aws_ecr_repository Hardcoded name = "acdl-microservice" default; encryption_configuration {} block (not a bare kms_key_arn arg).
13 aws:cloudfront:distribution aws_cloudfront_distribution origin {} block (origin_id, domain_name, origin_access_control_id, s3_origin_config {}); default_cache_behavior {} block (viewer_protocol_policy, target_origin_id, ttls, allowed/cached methods); enabled = true; price_class; restrictions { geo_restriction {} }; viewer_certificate { cloudfront_default_certificate = true }; web_acl_id from WAF ref. ~8 nested blocks.
14 aws:cloudfront:originaccesscontrol aws_cloudfront_origin_access_control name; hardcoded origin_access_control_origin_type = "s3", signing_behavior = "always", signing_protocol = "sigv4".
15 aws:wafv2:webacl aws_wafv2_web_acl name; hardcoded scope = "CLOUDFRONT"; default_action {} (allow/block from input, default allow); visibility_config {}; custom rule {} blocks as nested HCL (P1-4 fix) or default AWS-managed-rules block. ~5 nested blocks.
16 aws:rds:instance aws_db_instance NFR-derived backup_retention_period (default 7), deletion_protection (default true); storage_encrypted = true default; skip_final_snapshot = true (dev safety).
17 aws:kms:key aws_kms_key NFR-derived enable_key_rotation = true default.
18 aws:ecs:uptime-service aws_ecs_service Feature-flag gate (returns "" when disabled); container_definitions jsonencode for uptime-kuma; hardcoded subnets = ["subnet-uptime"], security_groups = ["sg-uptime"], assign_public_ip = true; hardcoded desired_count = 1, launch_type = "FARGATE".

Plus a global prevent_destroy lifecycle block emitted for every resource when nfrs.deletion_protection is true (line 576581), and the _emit_igw() helper that synthesizes an internet gateway + route table association from the VPC resource (line 585).

1.3 Hardcoded defaults that belong in the module

The defaults below are emitted by the adapter when the L2 composition does not supply the input. They are resource shape decisions — CIDR ranges, trust policies, network config — that belong in the module's locals.tf (D-100), not in the adapter. The adapter should pass only resolved contract inputs; if a default is wrong, fix the module, not the adapter.

Default Adapter line What it is Where it belongs
cidr_block = "10.0.0.0/16" 288 VPC CIDR default modules/l1/vpc/terraform/locals.tf
cidr_block = "10.0.1.0/24" 296 Subnet CIDR default modules/l1/vpc/terraform/locals.tf
ECS task execution assume_role_policy JSON 326331 Trust policy for the IAM role modules/l1/iam-role/terraform/main.tf (or locals.tf)
ECR/logs inline policy / encryption_configuration {} 304315, 380 ECR KMS encryption block modules/l1/ecr/terraform/main.tf
Fargate requires_compatibilities / launch_type = "FARGATE" 261263 ECS launch config modules/l1/ecs-service/terraform/locals.tf
assign_public_ip (uptime) 565 ECS network config modules/l1/uptime/terraform/main.tf
Listener/target ports (port = 8080, container_port = 8080) 201, 348 ALB + ECS container ports modules/l1/alb/terraform/locals.tf + modules/l1/ecs-service/terraform/locals.tf
Security group emission (security_groups = [...]) 255258, 564 ECS network config modules/l1/ecs-service/terraform/main.tf
name = "acdl-microservice" (cluster, ECR, service) 265, 300, 303 Resource name defaults modules/l1/*/terraform/locals.tf
family = "app" 279 Task definition family modules/l1/ecs-service/terraform/locals.tf
target_type = "ip", protocol = "HTTP" 345, 347 ALB target group defaults modules/l1/alb/terraform/locals.tf
load_balancer_type = "application" 342 ALB type default modules/l1/alb/terraform/locals.tf
desired_count = 1 260 ECS desired count modules/l1/ecs-service/terraform/locals.tf
WAF scope = "CLOUDFRONT", managed-rules default block 421, 472490 WAF defaults modules/l1/waf/terraform/main.tf
CloudFront signing_behavior = "always", signing_protocol = "sigv4", origin_type = "s3" 365367 OAC defaults modules/l1/cloudfront/terraform/main.tf
CloudFront viewer_certificate { cloudfront_default_certificate = true }, restrictions {} 403410 Distribution defaults modules/l1/cloudfront/terraform/main.tf
RDS backup_retention_period = 7, skip_final_snapshot = true 497, 506 RDS defaults modules/l1/rds/terraform/locals.tf
KMS enable_key_rotation = true 510 KMS rotation default modules/l1/kms-key/terraform/main.tf
prevent_destroy = true lifecycle (global) 576581 Deletion protection Each module's main.tf (or a shared lifecycle.tf)

1.4 Why this is a drift from the original vision

ARCHITECTURE.md §12.2 states: "The adapter is a thin layer; it does not own L1/L2 content — it only translates." STANDARDS.md §8 (line 448506) documents the intended design: "a thin translator with 3 tables + specialized branches." The drift was baked into the standards doc itself — §8.2 explicitly blesses "specialized _emit_resource branches" for "resources with nested HCL blocks" and §8.3 step 4 instructs module authors to "add a specialized branch in _emit_resource keyed on that stack type" when a new L1 needs nested blocks.

The result: every new L1 with a nested block (CloudFront, WAF, ECS, uptime) added 3080 lines of resource-shape code to the adapter. The adapter grew from a spike-era ~150 lines to 750 lines, with the resource shape (CIDR ranges, trust policies, container ports, managed-rule sets) encoded as Python string concatenation rather than Terraform HCL. D-098 corrects the drift: the standards doc §8 must be rewritten to document the new pattern (per-module terraform/ subdir + stateless assembler), and the "specialized branch" guidance is removed.

Confidence: 0.95. The audit is a direct line-by-line read of the v1.10.2 adapter.py; the drift is structural and unambiguous.


FINDING 2 — State-key root cause of the 4-VPC bug

2.1 The state key

adapter.py line 664 + 676:

stack_name = stack.get("name", "spike")
terraform_tf = (
    ...
    f'    key    = "spike/{stack_name}/terraform.tfstate"\n'
    ...
)

core/contract_resolver.py line 569:

"stack": {
    "name": contract["id"],
    ...
}

So stack_name = contract["id"] and the state key is spike/{contract.id}/terraform.tfstate.

2.2 The 5 microservice contracts

All five microservice contracts share id: msvc and differ only in environment:

Contract file id environment
contracts/microservice.yml msvc dev
contracts/microservice.dev.yml msvc dev
contracts/microservice.qa.yml msvc qa
contracts/microservice.prod.yml msvc prod
contracts/microservice.dr.yml msvc dr

The state key does not include the environment. So all four environment contracts (dev/qa/prod/dr) collide on the same state key: spike/msvc/terraform.tfstate.

2.3 The two root causes

Root cause 1 — the adapter emits per-contract state keys with no VPC sharing. The microservice composition (modules/l2/microservice/ composition.json) includes a vpc child (vpc@1.0.0). Every contract that resolves through this composition emits its own VPC resource. There is no platform VPC to share; each contract deploys its own VPC. D-105 corrects this: terraform/platform owns ONE VPC; the microservice composition drops its vpc child and references the platform VPC via a data source. The standalone vpc L1 module stays (consumers deploy their own VPCs). No per-contract VPC ever again.

Root cause 2 — the state key does not distinguish environments. Because the state key is spike/{contract.id}/terraform.tfstate and all four env contracts share id: msvc, every environment's terraform apply writes to the same remote state key. Combined with the first attempt's verify_deploy_microservice.py running terraform init -reconfigure in a fresh temp dir each time, each run created a fresh local state that diverged from the remote key. The first run (dev) created VPC #1 and pushed it to spike/msvc/terraform.tfstate. The second run (qa) ran -reconfigure in a fresh temp dir, pulled the remote state (which had dev's VPC), but because the local state was fresh and the composition emitted a new VPC resource address, terraform saw the VPC as "to add" again — creating VPC #2 and overwriting the remote state. Repeating for prod and dr created VPCs #3 and #4. Four VPCs, one state key, no environment discrimination.

D-106 corrects this: the composition must be deterministic — same contract → same resolved stack → same state key, every time. State keys become env-aware and stable across apply/modify/destroy: spike/{id}/{env}/terraform.tfstate. The environment is part of the key, so dev/qa/prod/dr never collide.

2.4 Why -reconfigure in a fresh temp dir made it worse

terraform init -reconfigure forces terraform to re-read the backend config and pull remote state into the local working directory. When the working directory is a fresh temp dir (as verify_deploy_microservice.py did), there is no local .terraform/ state cache — terraform must pull the remote state fresh. If the remote state key is shared across environments (root cause 2) and the composition emits a new VPC each time (root cause 1), the -reconfigure pull merges the prior environment's state with the new resource addresses, and the subsequent apply creates a new VPC because the resource address in the new composition run differs from the one in the remote state (the L2 namespacing or the fresh temp dir caused terraform to treat the VPC as a new resource). D-101 deletes verify_deploy_microservice.py entirely; run_platform.sh gains --apply and --destroy modes that run terraform in a stable working directory (not a fresh temp dir per run), and Python never runs terraform.

Confidence: 0.90. The state-key derivation is a direct code read (adapter.py:664,676 + contract_resolver.py:569). The 5 contracts are read verbatim. The 4-VPC mechanism is the only consistent explanation for the observed symptom (4 VPCs in the account after 4 env runs). The 0.10 residual is for the possibility that the resource-address divergence was caused by a separate composition-namespacing bug rather than the fresh temp dir alone — but either way, the two root causes (shared state key + per-contract VPC) are confirmed and D-105/D-106 correct both.


FINDING 3 — Per-module terraform module design

3.1 What the per-module terraform/ subdir should contain

D-098/D-099: each L1 module ships a real terraform/ module dir. The canonical layout for a multi-resource module:

modules/l1/<name>/
  interface.json          # engine-agnostic (unchanged)
  instance.json          # regression baseline (unchanged)
  README.md
  examples/
    simple.yml
    complex.yml
  terraform/              # NEW — the engine binding
    versions.tf           # required_version + required_providers
    variables.tf          # from interface.json inputs
    locals.tf             # default interpolation (heavy use, D-099)
    main.tf               # resource blocks (resource shape + nested blocks)
    outputs.tf            # from interface.json outputs

Trivial single-resource modules (e.g. s3) may inline locals in main.tf (D-099). Multi-resource modules (vpc, ecs-service, alb, microservice-shaped) get the full split.

3.2 The three reference modules (from interface.json)

s3 (modules/l1/s3/interface.json):

  • variables.tf: bucket_name (string, required), region (string, required), kms_key_arn (string, optional).
  • locals.tf: sse_algorithm = "aws:kms", versioning default true, managed-key fallback (alias/aws/s3 when kms_key_arn is null), the prevent_destroy lifecycle.
  • main.tf: resource "aws_s3_bucket" "this" { bucket = var.bucket_name ... } + versioning {} block + server_side_encryption_configuration {} block (CMK ref or managed fallback).
  • outputs.tf: bucket_arn (→ aws_s3_bucket.this.arn), bucket_name (→ aws_s3_bucket.this.id), bucket_regional_domain_name (→ aws_s3_bucket.this.bucket_regional_domain_name).
  • versions.tf: terraform { required_version = ">= 1.9, < 1.10" required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } } }.

vpc (modules/l1/vpc/interface.json — multi-resource: vpc + subnet + routetable):

  • variables.tf: cidr (string, required), azs (string, required), name (string, required), region (string, required).
  • locals.tf: cidr_block = coalesce(var.cidr, "10.0.0.0/16"), subnet CIDR derivation (cidrsubnets(local.cidr_block, 8, 8, ...) per AZ), name tag interpolation, the IGW + route table association.
  • main.tf: aws_vpc, aws_subnet (count/for_each over azs split), aws_route_table, aws_internet_gateway, aws_route_table_association — all the resources that the adapter's _emit_igw() helper synthesized dynamically now live here as real HCL.
  • outputs.tf: vpc_id, subnet_ids (join the subnet ids).
  • versions.tf: same provider block.

ecs-service (modules/l1/ecs-service/interface.json — multi-resource: task_definition + service):

  • variables.tf: image, port, cpu (default 256), memory (default 512), env (optional), cluster_arn, subnets, security_group, lb_target_group_arn (optional), region, kms_key_arn (optional), desired_count (default 1), launch_type (default "FARGATE"), family (default "app").
  • locals.tf: container_definitions jsonencode (image/port/env/cpu/ memory), requires_compatibilities = ["FARGATE"] when launch_type is FARGATE, log group name + KMS ref, the prevent_destroy lifecycle.
  • main.tf: aws_ecs_task_definition (family, container_definitions, requires_compatibilities, execution_role_arn) + aws_ecs_service (name, cluster, task_definition, desired_count, launch_type, network_configuration {}, load_balancer {} block).
  • outputs.tf: service_arn, task_def_arn.
  • versions.tf: same provider block.

3.3 How the stateless adapter assembles them

The new adapter (D-098) is a ~80-line stateless assembler. It:

  1. Reads modules/registry.json → for each resource in the resolved stack instance, looks up the L1 module by module field (<name>@<semver>).
  2. Gets the terraform_dir from the registry entry (or derives it as modules/l1/<name>/terraform/).
  3. Emits a root main.tf with one module "x" { source = "<terraform_dir>" ... } block per resource, passing the resolved contract inputs as module arguments.
  4. Wires refs via module "x".<output> interpolations: a ref:<id>.<out> input value becomes module.<id>.<out> in the consuming module block.
  5. Emits the stack-level output {} blocks (passthrough from the producing module's outputs).
  6. Emits terraform.tf (backend config with the env-aware state key, D-106) + providers.tf (aws provider, region from the first resource).

The adapter holds no TYPE_MAP, INPUT_MAP, OUTPUT_MAP, and no type-specific branches. The engine binding (stack type → Terraform resource type, input → arg name, output → attribute name, nested blocks, defaults) lives entirely in the per-module terraform/ subdir. interface.json stays engine-agnostic.

Confidence: 0.90. The module layout is grounded in the existing interface.json files (read verbatim) and the Terraform module convention (versions/variables/locals/main/outputs split). The assembler design is D-098/D-099 (user-confirmed). The 0.10 residual is for the exact terraform_dir registry field shape (not yet implemented) and the ref-wiring syntax (module.<id>.<out> vs a locals alias).


FINDING 4 — Existing pipeline architecture

4.1 The central pipeline contract

pipelines/contract.yml is the declarative deployment pipeline spec (a contract, not an executable workflow). It declares 9 stages: validate-contractresolve-stackterraform-plancheckovconfidenceapply (dev only) → publish-outputsdeploy-uptimecomment-outputs. Each stage has name, command, required (bool), and optional description. The executable workflow (.github/workflows/deploy.yml + .gitea/workflows/deploy.yml, byte-identical) implements these stages by invoking scripts/run_platform.sh. Validated against schemas/deploy-pipeline.schema.json.

4.2 The plan-only pipelines (existing, run on every PR)

Two platform pipelines run on every PR to main (offline, free):

Pipeline File Matrix What it does
Primitives plan .github/workflows/primitives-plan.yml (+ .gitea/ byte-identical) s3, vpc, ecs-cluster, ecs-service, iam-role, alb, ecr, cloudfront, waf, rds (10 primitives) For each L1 primitive, runs bash scripts/run_primitive_plan.sh --check-only <primitive> — resolves the primitive's instance.json, runs the adapter, validates the emitted Terraform structure (offline, no AWS).
Patterns plan .github/workflows/patterns-plan.yml (+ .gitea/ byte-identical) static-assets, microservice (2 modules) For each L2 module, runs bash scripts/run_pattern_plan.sh --check-only <module> — resolves the sample contract, runs the adapter, validates the emitted Terraform (offline).

Both trigger on pull_request: branches: [main], run on ubuntu-latest, install jsonschema pyyaml boto3. The --check-only mode is offline (no AWS, no Checkov, no DynamoDB) — it resolves the contract/instance, runs the adapter, and validates the emitted Terraform file structure. This is what makes the pipelines free.

4.3 run_platform.sh — plan only, never apply/destroy

scripts/run_platform.sh (521 lines) has three modes today:

  • --check-only (offline, no AWS): contract → resolver → adapter → stream TF → validate → exit 0.
  • --plan-only (requires AWS): contract → resolver → adapter → terraform init -reconfigure -lock=falseterraform validateterraform plan -lock=false -out=tfplan → exit 0 (line 274297).
  • default (requires AWS + Checkov + DynamoDB): contract → resolver → adapter → terraform plan → Checkov → confidence → outbox.

Critically, line 287 runs terraform plan only. There is no terraform apply and no terraform destroy in run_platform.sh today. The apply stage in pipelines/contract.yml (line 5457) declares command: bash scripts/run_platform.sh --plan-only — a misnomer; it runs plan, not apply. The lifecycle modes (--apply, --destroy) must be added (D-101). Python never runs terraform; run_platform.sh is the only shell entry point.

4.4 run_primitive_plan.sh

scripts/run_primitive_plan.sh (65 lines) runs the platform pipeline for a single primitive. --check-only mode: resolves instance.json, runs the adapter, validates the emitted {main.tf,terraform.tf,providers.tf} exist and main.tf is non-empty. Default mode (requires AWS): terraform init -backend=falseterraform validateterraform plan. This is the per-primitive plan check that the primitives-plan pipeline matrix invokes.

4.5 The byte-identical Gitea+GitHub convention

pipelines/README.md:22 documents the convention: "Create byte-identical workflow YAMLs in .gitea/workflows/<name>.yml and .github/workflows/<name>.yml." Both workflows must implement the same stages, commands, triggers, and runner declared in the contract. tests/test_pipeline_contract.py validates that the Gitea and GitHub workflow YAMLs are byte-identical and conform to the schema. The only difference is the forge runtime (Gitea Actions vs GitHub Actions). The new modules-lifecycle pipeline (D-102) must follow this convention: byte-identical .gitea/workflows/modules-lifecycle.yml + .github/workflows/modules-lifecycle.yml.

Confidence: 0.95. All pipeline files are read verbatim from the v1.10.2 tree. The "plan only, never apply/destroy" finding is a direct read of run_platform.sh line 287 + the --plan-only exit at line 293.


FINDING 5 — PERSONAS.md update for v1.11

The existing PERSONAS.md (v1.9) has 6 active personas: lead-developer, backend-engineer, platform-engineer (custom), security-engineer (custom), lambda-engineer (custom, v1.9), frontend-engineer. v1.11 changes the roster:

  • Deactivate lambda-engineer — no per-module Python this milestone (D-102: testing is pipeline-driven, not pytest). The v1.9 Lambda (core/lambda/contract_ingestor.py) persists but is not touched in v1.11.
  • Deactivate cost-engineer — not in the v1.9 roster (the v1.9 data-engineer is already deactivated). v1.11 has no cost-engineer work; cost is documented in COST.md (REQ-119) by the lead-developer.
  • Keep backend-engineer — owns the adapter rewrite (stateless assembler) + core/contract_resolver.py (env-aware state keys, D-106).
  • Keep data-engineer (reactivated) — owns terraform/ (platform VPC, D-105) + the per-module terraform/ subdirs (the engine binding, D-098/D-099/D-100). This is the heaviest territory in v1.11: 12 L1 modules each get a real terraform/ module dir.
  • Keep general (the lead-developer + backend-engineer pipeline work) — owns pipelines/ + .gitea/workflows/ + .github/workflows/ (the modules-lifecycle pipeline, D-102) + scripts/run_platform.sh (--apply/--destroy modes, D-101).

Territory alignment (v1.11)

Persona Territory Domain
backend-engineer adapters/terraform/adapter.py (rewrite to stateless assembler), core/contract_resolver.py (env-aware state keys), schemas/stack.schema.json (if touched) backend
data-engineer terraform/ (platform VPC, D-105), modules/l1/*/terraform/ (per-module terraform subdirs — the engine binding), modules/l1/*/interface.json (defaults move from adapter to interface), modules/registry.json (terraform_dir field) data
general (lead-developer + backend-engineer) pipelines/modules-lifecycle.yml, .gitea/workflows/modules-lifecycle.yml + .github/workflows/modules-lifecycle.yml (byte-identical), scripts/run_platform.sh (--apply/--destroy), scripts/run_primitive_plan.sh (if extended), modules/STANDARDS.md §8 rewrite coordination + pipelines

Territory enforcement: warn

Co-authoring is expected on the adapter + run_platform.sh boundary (backend-engineer rewrites the adapter; general adds the lifecycle modes to run_platform.sh that invoke it). warn keeps it frictionless — cross-territory edits are logged in the commit message but do not fail the task.

Domain priority (v1.11)

data → backend → general

Rationale: the terraform foundation (per-module terraform/ subdirs + platform VPC) is the binding constraint — the stateless adapter cannot be written until the reference s3 module exists (D-107: P56a proves the design with s3 first). Backend (adapter/resolver) follows once the module shape is proven. General (pipelines/workflows) wires the lifecycle modes last, once the adapter + modules produce valid terraform.

The updated PERSONAS.md is written to /root/acdl/.ciagent/PERSONAS.md (see that file). YAML frontmatter with active, phase_specific, and reason fields per persona.

Confidence: 0.90. The persona changes are grounded in the CLARIFY decisions (D-098..D-107) and the v1.11 scope (no per-module Python → lambda-engineer deactivated; terraform module authoring is the heaviest work → data-engineer reactivated).


Assumptions logged

ID Assumption Confidence Rationale
A-1.1 The terraform_dir field will be added to modules/registry.json entries (or derived as modules/l1/<name>/terraform/) so the stateless adapter can locate each module's terraform subdir. 0.85 D-098 says the adapter reads registry.json → gets terraform_dir. The exact field name is not yet locked; the derivation path is the obvious fallback.
A-1.2 The ref-wiring syntax in the root main.tf will be module.<id>.<output> (standard Terraform module output interpolation), not a locals alias. 0.85 The existing _ref_expr already produces <tf_type>.<id>.<attr>; the module equivalent is module.<id>.<output>. Standard Terraform convention.
A-2.1 The 4-VPC bug's resource-address divergence was caused by the fresh temp dir + -reconfigure pull merging remote state with new composition runs, not a separate composition-namespacing bug. 0.80 The two confirmed root causes (shared state key + per-contract VPC) are sufficient to explain 4 VPCs. The exact terraform-state mechanics of the divergence are inferred, not observed in a debug log.
A-3.1 Trivial single-resource modules (s3) may inline locals in main.tf; multi-resource modules (vpc, ecs-service, alb) get the full 5-file split. 0.90 D-099 states this explicitly.
A-4.1 The modules-lifecycle pipeline will matrix-run each L1 module's examples/{simple,complex}.yml contracts (the modify variants), not new contract files. 0.90 D-103: "Uses the module's own existing example contracts as the modify variants. No extra contract files needed."
A-5.1 platform-engineer and security-engineer from the v1.9 roster are folded into data-engineer and backend-engineer for v1.11 (the v1.11 scope is terraform + adapter + pipelines, not security adapters or HITL gates). 0.75 The v1.11 scope (D-097..D-107) does not touch Wiz/Kyverno/Checkov/HITL. The persona roster is simplified to the three active domains.

Decisions surfaced (research → already bound in CLARIFY)

All v1.11 binding decisions (D-097..D-107) were committed in the CLARIFY stage (80b7286) before this research ran. This research grounds those decisions with codebase evidence; it does not surface new binding decisions. The decisions are summarized in §Background above and documented in full in the CLARIFY commit.