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---
33 KiB
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 tagv1.10.2, per D-097). Sources: ACDL codebase (v1.10.2 tree) + git history (failed first attempt onphase/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:
- 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.jsonalready declares and hardcode defaults that belong in the module. v1.11 makes it a ~80-line stateless assembler; each L1 ships a realterraform/module dir that owns its resource shape, nested blocks, and defaults. - Terraform owns lifecycle (D-101). The first attempt added a Python
script (
verify_deploy_microservice.py) that ranterraform init -reconfigurein a fresh temp dir each time, which contributed to the 4-VPC bug. v1.11 deletes that script;run_platform.shgains--applyand--destroymodes; Python never runs terraform. - 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}.ymlcontracts 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
2–3 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 326–331); 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 576–581), 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 |
326–331 | Trust policy for the IAM role | modules/l1/iam-role/terraform/main.tf (or locals.tf) |
ECR/logs inline policy / encryption_configuration {} |
304–315, 380 | ECR KMS encryption block | modules/l1/ecr/terraform/main.tf |
Fargate requires_compatibilities / launch_type = "FARGATE" |
261–263 | 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 = [...]) |
255–258, 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, 472–490 | WAF defaults | modules/l1/waf/terraform/main.tf |
CloudFront signing_behavior = "always", signing_protocol = "sigv4", origin_type = "s3" |
365–367 | OAC defaults | modules/l1/cloudfront/terraform/main.tf |
CloudFront viewer_certificate { cloudfront_default_certificate = true }, restrictions {} |
403–410 | 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) |
576–581 | 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 448–506)
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 30–80 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 defaulttrue, managed-key fallback (alias/aws/s3whenkms_key_arnis null), theprevent_destroylifecycle.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),nametag interpolation, the IGW + route table association.main.tf:aws_vpc,aws_subnet(count/for_each overazssplit),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_definitionsjsonencode (image/port/env/cpu/ memory),requires_compatibilities = ["FARGATE"]when launch_type is FARGATE, log group name + KMS ref, theprevent_destroylifecycle.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:
- Reads
modules/registry.json→ for each resource in the resolved stack instance, looks up the L1 module bymodulefield (<name>@<semver>). - Gets the
terraform_dirfrom the registry entry (or derives it asmodules/l1/<name>/terraform/). - Emits a root
main.tfwith onemodule "x" { source = "<terraform_dir>" ... }block per resource, passing the resolved contract inputs as module arguments. - Wires refs via
module "x".<output>interpolations: aref:<id>.<out>input value becomesmodule.<id>.<out>in the consuming module block. - Emits the stack-level
output {}blocks (passthrough from the producing module's outputs). - 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-contract → resolve-stack → terraform-plan → checkov →
confidence → apply (dev only) → publish-outputs → deploy-uptime →
comment-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=false→terraform validate→terraform plan -lock=false -out=tfplan→ exit 0 (line 274–297).- 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 54–57) 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=false → terraform validate → terraform 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.9data-engineeris already deactivated). v1.11 has no cost-engineer work; cost is documented inCOST.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) — ownsterraform/(platform VPC, D-105) + the per-moduleterraform/subdirs (the engine binding, D-098/D-099/D-100). This is the heaviest territory in v1.11: 12 L1 modules each get a realterraform/module dir. - Keep
general(thelead-developer+backend-engineerpipeline work) — ownspipelines/+.gitea/workflows/+.github/workflows/(the modules-lifecycle pipeline, D-102) +scripts/run_platform.sh(--apply/--destroymodes, 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.