From a4b17d0f263e624cc96b4ba17c6f451f10e3dcb1 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 22 Jul 2026 20:15:59 +0000 Subject: [PATCH] fix(P26): resolve multi-resource L1 ref ids in contract resolver ---ci--- project: acdl phase: 26 milestone: v1.7 status: execute ---/ci--- The microservice pattern (and any L2 referencing multi-resource L1s like vpc) failed at the adapter stage because the resolver emitted refs using the child id (e.g. 'vpc') instead of the expanded sub-resource id (e.g. 'vpc-subnet'). The adapter's type_by_id table only knows the sub-resource ids, so ref:vpc.subnet_ids was an unknown resource id. Fix: - contract_resolver.py: child_outputs now maps {outputName -> resourceId} instead of just the interface outputs dict. For multi-resource L1s, the ref uses the sub-resource id that produces the output. For single-resource L1s, the resourceId == childId (unchanged behavior). - vpc interface.json: the subnet sub-resource output is 'subnet_ids' (matching the interface-level output name) instead of 'subnet_id'. - adapter.py OUTPUT_MAP: aws:ec2:subnet now maps both 'subnet_ids' and 'subnet_id' to 'id'. Verification: - microservice pattern check-only: PASS (11 resources) - static-assets pattern check-only: PASS (4 resources) - platform check-only: PASS - full test suite: 266 passed --- adapters/terraform/adapter.py | 2 +- core/contract_resolver.py | 41 ++++++++++++++++++++++++++++++----- modules/l1/vpc/interface.json | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py index 5397e38..abba509 100644 --- a/adapters/terraform/adapter.py +++ b/adapters/terraform/adapter.py @@ -68,7 +68,7 @@ INPUT_MAP = { OUTPUT_MAP = { "aws:s3:bucket": {"bucket_arn": "arn", "bucket_name": "id"}, "aws:ec2:vpc": {"vpc_id": "id"}, - "aws:ec2:subnet": {"subnet_id": "id"}, + "aws:ec2:subnet": {"subnet_ids": "id", "subnet_id": "id"}, "aws:ec2:routetable": {}, "aws:ecs:cluster": {"cluster_arn": "arn", "cluster_id": "id"}, "aws:ecs:task_definition": {"task_def_arn": "arn"}, diff --git a/core/contract_resolver.py b/core/contract_resolver.py index c8a0995..c29c43d 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -44,7 +44,13 @@ def _resolve_wire_value(wire, contract_inputs, child_outputs): - ".outputs." — a reference to another child's output Returns either a concrete value (string/number/boolean) or a - "ref:." string for cross-child references. + "ref:." string for cross-child references. + + For multi-resource L1s (e.g. vpc which expands to vpc-vpc, vpc-subnet, + vpc-routetable), the ref must point to the sub-resource that actually + produces the output, not the child id. The child_outputs table maps + childId -> {outputName -> resourceId} so the ref uses the correct + resource id. """ from_expr = wire["from"] to_expr = wire["to"] @@ -66,7 +72,13 @@ def _resolve_wire_value(wire, contract_inputs, child_outputs): if len(parts) >= 3 and parts[1] == "outputs": child_id = parts[0] output_name = parts[2] - return f"ref:{child_id}.{output_name}" + # Look up the sub-resource that produces this output. + # child_outputs[child_id] is a dict {outputName -> resourceId}. + # If the child is a single-resource L1, the resourceId == child_id. + # If multi-resource, the resourceId is the expanded sub-resource id. + child_out_map = child_outputs.get(child_id, {}) + resource_id = child_out_map.get(output_name, child_id) + return f"ref:{resource_id}.{output_name}" return None @@ -125,6 +137,9 @@ def resolve_l2(contract, registry, repo_root): composition = _load_json(comp_path) # Track child outputs for wire resolution + # child_outputs[childId] = {outputName: resourceId} + # For single-resource L1s, resourceId == childId + # For multi-resource L1s, resourceId is the expanded sub-resource id child_outputs = {} resources = [] @@ -139,15 +154,18 @@ def resolve_l2(contract, registry, repo_root): child_iface_path = os.path.join(repo_root, child_entry["interface"]) child_iface = _load_json(child_iface_path) + # Build the output->resourceId map for this child + child_out_map = {} + # For multi-resource L1s (like vpc), the first resource type is the # primary; the adapter handles expansion. Use the interface's type # or the first resource in the interface's resources array. if "resources" in child_iface and child_iface["resources"]: # Multi-resource L1: create one resource per sub-resource for sub_res in child_iface["resources"]: + res_id = f"{child_id}-{sub_res['type'].split(':')[-1].replace('_', '-')}" if len(child_iface["resources"]) > 1 else child_id resource = { - "id": f"{child_id}-{sub_res['type'].split(':')[-1].replace('_', '-')}" - if len(child_iface["resources"]) > 1 else child_id, + "id": res_id, "type": sub_res["type"], "module": child_module, "inputs": {}, @@ -157,6 +175,9 @@ def resolve_l2(contract, registry, repo_root): }, } resources.append(resource) + # Map each output to this sub-resource's id + for out_name in sub_res.get("outputs", []): + child_out_map[out_name] = res_id else: # Single-resource L1 resource = { @@ -170,9 +191,17 @@ def resolve_l2(contract, registry, repo_root): }, } resources.append(resource) + # Map each output to the child id + for out_name in child_iface.get("outputs", {}): + child_out_map[out_name] = child_id - # Track outputs for this child - child_outputs[child_id] = child_iface.get("outputs", {}) + # Also map interface-level outputs (for L1s that declare outputs at the + # interface level rather than per-resource) + for out_name in child_iface.get("outputs", {}): + if out_name not in child_out_map: + child_out_map[out_name] = child_id + + child_outputs[child_id] = child_out_map # Resolve wires to populate inputs for wire in composition.get("wires", []): diff --git a/modules/l1/vpc/interface.json b/modules/l1/vpc/interface.json index 15b9d7c..8e79c97 100644 --- a/modules/l1/vpc/interface.json +++ b/modules/l1/vpc/interface.json @@ -48,7 +48,7 @@ "type": "aws:ec2:subnet", "description": "One subnet per availability zone (azs split on comma).", "inputs": ["cidr", "az", "vpc_id", "name"], - "outputs": ["subnet_id"] + "outputs": ["subnet_ids"] }, { "type": "aws:ec2:routetable",