{"owner":"GoogleCloudPlatform","repo":"cloud-foundation-fabric","hasSkills":true,"totalSkillsCount":38,"totalTokensCount":97171,"categories":["root-instruction","anthropic-skill","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md","skills/README.md","skills/contributing/SKILL.md","skills/fabric-builder/SKILL.md","skills/fabric-builder/references/conventions.md","skills/fabric-importer/COVERAGE.md","skills/fabric-importer/README.md","skills/fabric-importer/SKILL.md","skills/fabric-importer/examples/import-manifest.fast-networking.yaml","skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml","skills/fabric-importer/examples/import-manifest.fast-security.yaml","skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml","skills/fabric-importer/examples/import-manifest.multi-domain.yaml","skills/fabric-importer/examples/import-manifest.org-foundation.yaml","skills/fabric-importer/examples/waivers.example.yaml","skills/fabric-importer/references/address-map.yaml","skills/fabric-importer/references/cai-blind-spots.md","skills/fabric-importer/references/inferring-manifests-from-state.md","skills/fabric-importer/references/manifest-migration.md","skills/fabric-importer/references/mapping-cookbook.md","skills/fabric-importer/references/operating-contract.md","skills/fabric-importer/scripts/benign-drift.yaml","skills/fast/networking/SKILL.md","skills/fast/networking/references/1-requirements-to-topology.md","skills/fast/networking/references/2-routing-and-security-mechanisms.md","skills/fast/networking/references/3-stage-boundaries-and-plumbing.md","skills/fast/networking/references/4-dataset-schema-and-tokens.md","skills/fast/prerequisites/SKILL.md","skills/fast/prerequisites/TESTING.md","skills/fast/prerequisites/references/phase1-env-and-auth.md","skills/fast/prerequisites/references/phase2-admin-and-baseline.md","skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md","skills/fast/prerequisites/references/phase4-config-and-wrapup.md","skills/maintenance/release-process/SKILL.md","tools/README.md","tools/skill-turn-harness/README.md","tools/skill-turn-harness/docs/DESIGN.md","tools/skill-turn-harness/docs/Hybrid Test Harness Architecture.md"],"skills":{"AGENTS.md":"GEMINI.md","skills/README.md":"# Fabric Skills\n\nThis directory contains skills for AI agents to leverage Cloud Foundation Fabric (CFF). These skills provide context, guidelines, and tools for agents to perform specific tasks related to CFF.\n\n## Installing with `agents-cli`\n\nYou can install the user-facing Fabric skills (`fabric-builder`, `fast-networking`, and `fast-prerequisites`) into your project's `.agents/skills/` directory (or globally into `~/.agents/skills/` with `--global`) using [`agents-cli`](https://google.github.io/agents-cli/guide/extensions/):\n\n```bash\nagents-cli extension add GoogleCloudPlatform/cloud-foundation-fabric\n```\n\n## Available Skills\n\n- **[contributing](./contributing)**: End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\n- **[fabric-builder](./fabric-builder)**: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n- **[fabric-importer](./fabric-importer)**: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n- **[fast/networking](./fast/networking)**: Designs, customizes, and validates FAST `2-networking` YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a `2-networking` dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a `2-networking` dataset.\n- **[fast/prerequisites](./fast/prerequisites)**: Guides the user step-by-step through the prerequisites for the FAST `0-org-setup` stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for `0-org-setup` or bootstrap the FAST landing zone.\n- **[maintenance/release-process](./maintenance/release-process)**: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n","skills/contributing/SKILL.md":"---\nname: contributing\ndescription: \"End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\"\n---\n\n# Fabric Contribution Flow Skill\n\nThis skill defines the end-to-end workflow for contributing to Cloud Foundation Fabric. It supports two entry modes:\n- **Mode A: Issue Triage & Bug Fix**: You are addressing an assigned or reported GitHub Issue. Start at **Step 1**.\n- **Mode B: Proactive Development & PR Prep**: You are actively building a feature, refactoring code, or preparing an existing branch for a Pull Request. Jump directly to the **Design Sign-off Gate** before Step 2.\n\nIf it is unclear which mode applies, ask the user before proceeding.\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where your judgment is likely to diverge from a maintainer's. Keep mechanical, reversible steps (linting, tests, doc and inventory regeneration) autonomous. Gates are **blocking**: if you are running non-interactively and cannot get an answer, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Triage Disposition** | End of Step 1 (Mode A) | Whether the issue is in scope and worth pursuing: proceed, reject, or escalate. |\n| **Design Sign-off** | Before Step 2 | Approves the proposed design and scope. Mandatory for new features and interface changes; skippable for trivial fixes where the design is self-evident. |\n| **E2E Opt-in** | Step 5 | Whether to run live cloud verification. Providing a sandbox project ID **is** the consent to deploy to it. |\n| **Behavioral Verification Opt-in** | Step 5, after read-back verification | Whether to also verify runtime behavior of the deployed resources, given the described extra time and cost. |\n| **PR Approval** | Step 6 | Reviews the final sanitized PR body before submission. |\n\n---\n\n## Step-by-Step Workflow\n\n```mermaid\ngraph TD\n    M1[Mode A: Issue Triage] --> A[1. Triage Issue]\n    A --> GT{Gate: Triage Disposition}\n    GT -->|Human approves| GD{Gate: Design Sign-off}\n    GT -->|Human rejects| X[Stop / Report Back]\n    M2[Mode B: Proactive Dev] --> GD\n    GD --> B[2. Develop Fix / Feature]\n    B --> C[3. Tests, Lint & Inventories]\n    C --> D[4. Pre-Submission Self-Review]\n    D -->|Issues found| B\n    D -->|Clean| GE{Gate: E2E Opt-in}\n    GE -->|Project ID provided| G[5. Live E2E Verification]\n    GE -->|Skipped| GS{Gate: PR Approval}\n    G -->|Failures| B\n    G -->|Verified| GS\n    GS --> F[6. Commit & Submit Sanitized PR]\n```\n\n### Step 1: Triage the Issue (Mode A Only)\n\n1.  **Retrieve Issue Details**: Use the GitHub CLI to view the issue context.\n    ```bash\n    gh issue view <issue-number>\n    ```\n\n2.  **Explore the Codebase**: Identify the target module (`modules/<module-name>`) or FAST stage (`fast/stages/<stage-name>`) that requires modification.\n\n3.  **Evaluate Fit & Scope**: Assess whether the issue is relevant for Fabric. Ensure it aligns with Fabric's core philosophy (modules should be lean, composable, and represent a single resource type context). Confirm the change has a sufficiently large scope and represents a generic, valuable addition to the module or FAST stage rather than a highly specific, one-off customization.\n\n4.  **Read Provider Documentation**: If the issue involves Google Cloud resources, retrieve and read the documentation for the involved GCP resources or Terraform provider resource/datasource to ensure accurate implementation of its attributes, behaviors, and constraints.\n    *   Start from the provider registry, e.g. <https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_address>.\n    *   **Check the pinned provider range**: the repo pins the `google` provider in [default-versions.tf](../../default-versions.tf). If the feature you are implementing requires provider functionality released after the pinned minimum version, that is a good reason to bump the pin — do it across the board with `uv run tools/versions.py --provider-min-version <x.y.z> --write-defaults`, which rewrites `default-versions.tf` and propagates it to every module and stage `versions.tf` (linting enforces they stay in sync). Never edit version pins in individual modules only.\n    *   Only rely on documentation for functionality available in a **released** provider version: docs from the provider's `main` branch may describe unreleased attributes that no released provider accepts at plan time.\n    > [!WARNING]\n    > Do NOT rely solely on the proposed solution, examples, or partial specifications provided in the issue description. Always retrieve and review the complete documentation schema from the official provider registry.\n    >\n    > **Workaround for Registry Page JS/Redirection Errors**:\n    > If the registry page fails to load properly with your URL-fetching tool (e.g. returns \"Please enable Javascript\" or truncates due to HTML formatting issues), find the source markdown file on GitHub and fetch its raw content using `curl` to a scratch file (the `scratch/` directory is gitignored), using the tag of the provider release whose docs you need (e.g. `v7.40.0`):\n    > ```bash\n    > curl -s https://raw.githubusercontent.com/hashicorp/terraform-provider-google/v7.40.0/website/docs/r/workstations_workstation_config.html.markdown -o scratch/workstation_config_doc.md\n    > ```\n    > You can then search and view it locally to identify all supported arguments and blocks.\n    > Use this information to make an informed decision on which arguments to support. While 100% coverage is not always necessary or desirable, make a deliberate choice to include all arguments that are useful and align with Fabric's composability and simplicity goals, rather than missing them by omission.\n\n5.  **Gate — Triage Disposition**: Present your assessment (target module/stage, fit with Fabric's philosophy, scope evaluation) and let the human decide whether to proceed. If the issue fails the fit test, report your reasoning and stop — do NOT comment on, label, or close the issue yourself; disposition of the issue is a maintainer decision.\n\n### Gate: Design Sign-off (Modes A & B)\n\nBefore writing code, present the proposed design for approval:\n*   The target module(s)/stage(s) and the shape of the variable interface (new/changed variables, whether `context` support is added).\n*   For provider-surface changes: the explicit list of provider arguments you intend to include and exclude, with reasoning.\n*   Whether the change requires bumping the pinned provider version (a repo-wide change — see Step 1.4).\n\nThis gate is **mandatory for new features and interface changes**. For trivial fixes (typos, one-line bugfixes with an obvious solution) where the design is self-evident, state your intent and proceed without blocking.\n\n### Step 2: Develop the Fix or Feature (Modes A & B)\n\n1.  **Align with Fabric Zen**: Ensure the design focuses on composition, encapsulates logical entities (like IAM and log sinks directly inside modules), adopts common interfaces, and keeps code flat and easy to evolve.\n    > [!IMPORTANT]\n    > You MUST strictly follow the design principles and coding conventions defined in [CONTRIBUTING.md](../../CONTRIBUTING.md) and [GEMINI.md](../../GEMINI.md) when designing variables, modules, and factories.\n\n2.  **Set Up the Git Branch**:\n    *   If you are not already on a feature branch, create one named after your username and the feature/fix (e.g., `<username>/<feature-name>`, such as `johndoe/feature-name`):\n        ```bash\n        git checkout master && git pull\n        git checkout -b <username>/<feature-name>\n        ```\n    *   If preparing an existing branch (Mode B), do NOT create a new one; instead make sure it is up to date with `master` (rebase or merge) before submission.\n    *   Direct branches require push access to the repo (granted to regular contributors on request). Without it, use the fork-based flow described in [CONTRIBUTING.md](../../CONTRIBUTING.md); the rest of this workflow is identical.\n\n3.  **Apply Fabric/FAST Design Conventions**:\n    *   **Context Interpolation**: If context support is relevant and needed for the module (e.g., to support resolving symbolic references like `\"$project_ids:myprj\"`), add a `context` variable block and implement `ctx`/`ctx_p` locals in `main.tf`. Do not add it blindly if the module does not benefit from symbolic interpolation.\n    *   **Compact Variables**: Leverage objects with `optional()` attributes to keep user interfaces clean. Prefer `optional()` **without** an explicit default so the provider applies whatever default value it defines; only set a default when module logic needs a known value.\n    *   **Stable State Keys**: Always use maps instead of lists for collection variables to avoid index shifts in Terraform state.\n    *   **Scope Isolation**: Use private locals (prefixed with `_`) for intermediate transformations, reserving module-level locals for values referenced by resources.\n    *   **Locals Separation for Complex Manipulations**: When adding complex conditional strings, string interpolations, or transformations to resource attributes, compute the map in `locals` so the resource block references a clean index like `local.my_map[each.key]`. This keeps resource blocks legible (<79 chars) and avoids cluttering resource definitions.\n    *   **Validate ENUM Variables**: When adding or exposing variables that mirror ENUM values in the underlying Terraform provider, always include a `validation` block to check for allowed values at plan time. Example from `modules/gcs`:\n        ```hcl\n        variable \"storage_class\" {\n          description = \"Bucket storage class.\"\n          type        = string\n          default     = \"STANDARD\"\n          validation {\n            condition     = contains([\"STANDARD\", \"MULTI_REGIONAL\", \"REGIONAL\", \"NEARLINE\", \"COLDLINE\", \"ARCHIVE\"], var.storage_class)\n            error_message = \"Storage class must be one of STANDARD, MULTI_REGIONAL, REGIONAL, NEARLINE, COLDLINE, ARCHIVE.\"\n          }\n        }\n        ```\n\n4.  **Reference Implementations & Knowledge Sources**:\n    *   [compute-vm](../../modules/compute-vm): Ideal reference for compact variable design, optional objects with defaults, and map variables.\n    *   [project](../../modules/project): Best reference for logical entity encapsulation (IAM, log sinks, Shared VPC configurations), complex locals transformation, and context-based interpolation.\n    *   [adrs/](../../adrs/): Architectural Decision Records documenting the rationale behind key design patterns (e.g. the IAM ADR explains the full `iam*` interface). Before designing, build an index of all ADRs with their title and status:\n        ```bash\n        find adrs -name '*.md' ! -name 'README.md' | sort | while read -r f; do\n          st=$(awk '/^## Status/ {getline; while ($0 == \"\") getline; print; exit}' \"$f\")\n          printf '%s | %s | %s\\n' \"$f\" \"$(head -1 \"$f\" | sed 's/^# //')\" \"$st\"\n        done\n        ```\n        Read in full any ADR whose title relates to the area you are changing. Weigh them by status: ignore `Rejected` ones, treat `Draft`/`Proposed` as directional rather than settled, and remember that even implemented ADRs can lag behind the code — on conflict, the current code and golden-path modules are the source of truth.\n\n5.  **Maintain Code Consistency**:\n    *   Always keep variables in `variables.tf` and outputs in `outputs.tf` in strict alphabetical order. For larger modules, variables and outputs can be grouped logically into separate files (e.g. `variables-logging.tf`), each internally alphabetically sorted.\n    *   Limit line length to 79 characters (relaxed for long attribute names and descriptions).\n    *   **JSON Schema & Factory Alignment**: When a change is made to a module that implements a factory (e.g., `cloud-workstations`) or is the base for one (e.g., `net-vpc-factory` to `2-networking`), the schemas MUST be updated (including their `.schema.md` documentation files), and the factory code should be updated to mirror the changed variable surface. Ensure you regenerate the schema documentation by running:\n        ```bash\n        uv run tools/schema_docs.py\n        ```\n    *   **Duplicated Files**: Some files (e.g. schemas, factory test fixtures) are intentionally duplicated across modules and stages, and CI enforces that copies stay identical. When changing such a file, sync all its copies; the duplicate groups are listed in [tools/duplicate-diff.py](../../tools/duplicate-diff.py) — update that list when adding new duplicated files.\n\n6.  **Update or Add Tests**:\n    *   For modules: If the change introduces a new feature or configuration option, update an existing example in the module's `README.md` (or add a new one) to demonstrate its usage. Ensure the example includes `# tftest` parameters and update the corresponding inventory YAML files under `tests/modules/<module_name>/examples/`.\n    *   For FAST stages: Update `tftest.yaml` scenarios and tfvars/yaml inventories under `tests/fast/...`.\n    > [!NOTE]\n    > Module directories use hyphens (`modules/net-vpn-ha`) while test directories generally use underscores (`tests/modules/net_vpn_ha`). Always check the actual test directory name — a mistyped path makes `pytest` silently collect zero tests.\n\n### Step 3: Run Tests, Linting & Inventory Regeneration (Modes A & B)\n\nRun Python tooling through `uv run` — the scripts in `tools/` declare their dependencies inline, so no virtual environment setup is needed. For faster Terraform testing, always set `TF_PLUGIN_CACHE_DIR=/tmp/tfcache`.\n\n1.  **Run Unified Linting**: Execute `tools/lint.sh` to check copyright boilerplates, Terraform format (`terraform fmt`), alphabetical sorting, and schema validations:\n    ```bash\n    uv run --with-requirements tools/requirements.txt tools/lint.sh\n    ```\n\n2.  **Update Documentation**: If you changed variables or outputs, check consistency and regenerate the README documentation tables:\n    ```bash\n    uv run tools/check_documentation.py modules/<module-name>\n    uv run tools/tfdoc.py --replace modules/<module-name>\n    ```\n\n3.  **Run Impacted Tests**: Execute `pytest` on the target module/stage (using the plugin cache directory for speed):\n    ```bash\n    mkdir -p /tmp/tfcache\n    # tftest.yaml scenarios (note: underscores in the test directory name)\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest tests/modules/<module_name>\n    # README example tests\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest -k 'modules and <module-name>:' tests/examples\n    ```\n\n4.  **Regenerate Test Inventories**: If module-level tests (`tftest.yaml`) or README example inventories fail due to intentional plan output changes, regenerate them using `generate_plan_summary.py`:\n    ```bash\n    # For module-level tftest.yaml inventories:\n    uv run tools/generate_plan_summary.py tests/modules/<module_name>/tftest.yaml <test-name> --save\n\n    # For README.md example inventories:\n    uv run tools/generate_plan_summary.py modules/<module-name>/README.md \"<Example Heading>\" --save\n    ```\n    > [!CAUTION]\n    > `--save` overwrites the assertion baseline, so a regenerated inventory makes the test pass **by construction**. After regenerating, diff the inventory against the previous version (`git diff tests/`), verify that every changed line maps to the intended feature or fix, and report the inventory diff to the user. If a changed line is not explained by your change, treat it as a bug in your code, not a baseline to save.\n\n### Step 4: Pre-Submission Self-Review (Modes A & B)\n\nReview your own diff (`git diff HEAD`, or `git diff master...HEAD` for an existing branch) against the repository guidelines (`GEMINI.md`, `CONTRIBUTING.md`) before submission. This is a pre-flight self-check; the canonical automated review runs in CI after the PR is opened (`Automated PR Review` workflow) — do NOT imitate its output format or header.\n\nCheck the diff against this checklist:\n\n*   [ ] Naming conventions respected (variables, outputs, resources).\n*   [ ] Context support (`ctx` variables) present where the module warrants it.\n*   [ ] IAM follows the standard `iam*` interface patterns.\n*   [ ] Examples with `# tftest` parameters cover the new/changed behavior.\n*   [ ] **CRITICAL TESTING RULE**: if resource blocks were modified (adding a new argument or modifying an existing one), the Terraform plan output changes — the corresponding test inventory YAML files (`tests/.../*.yaml`) MUST be updated in the diff. If they are not, this is a critical testing failure.\n*   [ ] JSON schemas and factory code consistent with the changed variable surface.\n*   [ ] Documentation tables regenerated (`tfdoc`), alphabetical order preserved.\n*   [ ] **Breaking Changes**: If any variable or YAML interface changed in a backwards-incompatible way, verify the PR description draft includes the `**Breaking Changes**` / `upgrade-note` block.\n\nReport the outcome as a short plain-text list of issues found (or \"no issues found\") under a `Pre-submission self-review` heading — no emojis, no status tables. Fix any issue and loop back to Step 3 until the checklist passes.\n\n### Step 5: Live Verification — E2E Sandbox & Policy Troubleshooter (Modes A & B — Optional / Recommended)\n\nWhen code modifications affect GCP resource structures or APIs, run a live E2E sandbox deployment test. Run it only once the diff is stable (tests and self-review pass) to avoid repeated cloud deployments.\n\n1.  **Gate — E2E Opt-in**: Ask the user for a GCP project ID (and if applicable, parent folder / billing account details) for E2E sandbox testing, stating explicitly that providing the project ID authorizes you to deploy and destroy resources in it with `-auto-approve`. If no project is provided, skip this step entirely.\n\n2.  **Verify Credentials**: Confirm `gcloud` authentication and Application Default Credentials are available (`gcloud auth list`) before attempting any deployment, and surface a clear error rather than failing mid-apply.\n\n3.  **Create Sandbox Directory**: Create a temporary sandbox folder under `scratch/e2e_sandbox/` (the `scratch/` directory is gitignored).\n\n4.  **Generate Test Configuration**:\n    *   Generate a root Terraform module `main.tf` in the sandbox directory.\n    *   **CRITICAL**: The `source` argument of the module call MUST point to the **local path** of the modified module in the repository (e.g. `source = \"../../modules/<module-name>\"`), NOT the GitHub reference, to ensure your local changes are tested.\n    *   Set up necessary providers and variables.\n\n5.  **Deploy**:\n    *   Run `terraform init` and `terraform apply -auto-approve` in the sandbox folder.\n    *   Confirm that all resources are created successfully. A clean apply is the baseline, not the goal: it only proves the API accepted the request, not that the change behaves as intended.\n\n6.  **Read-Back Verification (required)**:\n    *   Verify the deployed state through the service's read API (e.g. `gcloud compute backend-services describe`, or the equivalent `GET`/`describe` surface for the resource), not just the Terraform state or plan output.\n    *   For every field or block touched by the change, confirm the live resource contains the intended value and structure (e.g. one API list entry per input element, correct nesting, no silently dropped attributes).\n\n7.  **Behavioral Verification (optional — Gate)**:\n    *   **Gate — Behavioral Verification Opt-in**: after read-back verification passes, explicitly ask the user whether to also verify runtime behavior, describing what the check would entail for this specific change (resources involved, expected extra time such as propagation delays, any additional cost). Proceed only on explicit approval; skipping is a valid outcome and must be recorded in the PR body.\n    *   Verify the change behaves as expected from the perspective of a consumer of the deployed infrastructure. Examples:\n        *   Compute instances: connect to the instance (SSH/serial) and check it is reachable and its startup configuration behaves as expected.\n        *   Networking, load balancers, DNS: send traffic through the deployed path and verify routing, reachability, and response attributes (e.g. `curl` through a load balancer and inspect status and headers, resolve records, test connectivity across peerings or VPN tunnels).\n        *   Serverless and workloads (Cloud Run, Cloud Functions, GKE): invoke the deployed service and verify the response.\n        *   Data services (GCS, BigQuery, Pub/Sub): perform a representative read/write operation (upload an object, run a query, publish and pull a message).\n    *   Account for propagation delays (load balancers, CDN, IAM can take several minutes); retry before concluding failure.\n    *   **IAM conditional bindings**: use Google Cloud's official IAM Policy Troubleshooter as the behavioral check (`gcloud policy-troubleshoot iam`) to verify runtime evaluation against live target resources.\n    *   **CRITICAL**: GCP IAM runtime evaluates `resource.name` using numeric Project Numbers. Always test conditions against `--resource-name` with the numeric Project Number:\n        ```bash\n        gcloud policy-troubleshoot iam //logging.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --permission=logging.buckets.write \\\n          --principal-email=<TEST_PRINCIPAL_EMAIL> \\\n          --resource-name=projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --format=\"json(access,explainedPolicies[].bindingExplanations)\"\n        ```\n\n8.  **Destroy Resources**:\n    *   Once verified, run `terraform destroy -auto-approve` to tear down all created resources and avoid ongoing cloud costs.\n    *   Delete the sandbox directory.\n    *   If verification surfaced failures, fix the code and loop back to Step 3 before re-deploying.\n\n### Step 6: Commit & Submit the PR (Modes A & B)\n\n1.  **Sanitize Before Committing**: PII sanitization applies to **everything that leaves your machine** — commits, file contents, and the PR body. Never commit files containing real GCP project IDs, numeric project numbers, personal email addresses, or custom resource names from live testing; verify `git status` shows no stray sandbox or scratch files before staging.\n\n2.  **Commit and Push**: Stage the relevant files, commit with a short imperative message describing the change, make sure the branch is up to date with `master` (rebase if needed), and push:\n    ```bash\n    git add <files>\n    git commit -m \"Add <feature> to modules/<module-name>\"\n    git push -u origin <username>/<feature-name>\n    ```\n\n3.  **Format the PR Title**: Do NOT use Conventional Commits format (no `feat:` or `fix:` prefixes). Use a short, capitalized, imperative title (e.g., \"Add native tag bindings support to `modules/net-firewall-policy`\").\n\n4.  **Write the PR Body**:\n    *   Explain the problem, rationale, and the fix clearly.\n    *   **Document Verification & E2E Testing Methodology**: Detail local unit tests and, for E2E runs, the apply, read-back, and behavioral verification results (or note that behavioral verification was skipped) so reviewers can see the exact testing rationale and methodology.\n    *   **CRITICAL PII SANITIZATION**: Before writing the PR description, **MUST scrub all developer PII** (real GCP project IDs, numeric project numbers, personal email addresses, usernames, and custom bucket/resource names) and replace them with generic placeholders (e.g., `my-project`, `123456789012`, `user:tester@example.com`, `test-bucket`).\n    *   **Breaking Changes & Upgrade Notes**: Whenever a change requires users to upgrade their variable or YAML surface (e.g. variable type changes, deleted attributes, changed schema structures), you **MUST append** a `**Breaking Changes**` section with an `upgrade-note` code fence at the bottom of the PR description. This is automatically parsed by `tools/changelog.py` when generating release notes.\n        Format:\n        ````markdown\n        **Breaking Changes**\n\n        ```upgrade-note\n        `modules/organization`: `custom_roles` variable type changed from `map(list(string))` to `map(object({...}))`. Existing code passing permission lists directly must be updated to pass `{ permissions = [...] }`.\n        ```\n        ````\n    *   Do NOT touch `CHANGELOG.md`: release notes are generated from PR labels and `upgrade-note` blocks by maintainers using `tools/changelog.py`.\n\n5.  **Gate — PR Approval**: Present the final sanitized PR title and body to the user and get explicit approval before creating the PR.\n\n6.  **Create the PR**:\n    *   **CRITICAL PITFALL**: Do NOT pass the body inline on the CLI if it contains backticks (e.g. `gh pr create --body \"Fixes `bug`\"`) as the shell will evaluate backticks, corrupting the description. Do NOT use shell redirection or heredocs to create the body file either (see `GEMINI.md`).\n    *   Instead, write the body to a temporary file **using your file-writing tool**, then reference it:\n        ```bash\n        gh pr create --title \"Your PR Title\" --body-file /tmp/pr-body.md\n        ```\n","skills/fabric-builder/SKILL.md":"---\nname: fabric-builder\ndescription: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n---\n\n# Fabric Builder\n\nThis skill generates idiomatic Terraform code using Cloud Foundation Fabric (CFF) modules, following established best practices and conventions.\n\n## Core Workflow\n\n1. **Understand Request:** Identify the GCP resources and relationships requested by the user.\n2. **Fetch Module Info:** Identify the relevant CFF module(s) from the `modules/` folder on GitHub (`GoogleCloudPlatform/cloud-foundation-fabric`).\n3. **Generate Terraform:** Output an idiomatic Terraform root module that consumes the CFF modules according to [conventions.md](references/conventions.md).\n\n## Important Guidance\n\n- **Conventions & Best Practices:** Consult [conventions.md](references/conventions.md) for guidelines on how to consume Fabric modules and write high-quality, idiomatic Terraform code (including module sources, version constraints, and provider configuration).\n- **Fetching Modules:** Do not invent module inputs or outputs. Use the `fabric.py` script to pull exact details (README, variables, examples) for a specific module from GitHub before using it.\n  - To list available modules: `python3 scripts/fabric.py modules`\n  - To fetch details for a module (README): `python3 scripts/fabric.py fetch readme <module_name>`\n  - To fetch variables files: `python3 scripts/fabric.py fetch variables <module_name>`\n  - To fetch outputs files: `python3 scripts/fabric.py fetch outputs <module_name>`\n  - To fetch schema files (useful for factories): `python3 scripts/fabric.py fetch schemas <module_name>`\n  - To fetch the latest release version: `python3 scripts/fabric.py release`\n  - **SSL Certification Verification**: If you run into SSL certificate issues on your host machine, use the `--no-ssl-verify` flag (e.g., `python3 scripts/fabric.py --no-ssl-verify fetch readme <module_name>`).\n\n## Guidelines for Output\n\n- **Root Module Output:** Your output must be a complete Terraform root module that calls the appropriate CFF modules to fulfill the user's requirements.\n- **Follow Conventions:** Strictly adhere to [conventions.md](references/conventions.md) for module sources, version constraints, provider configuration, and coding style.\n- **Use Fabric Modules:** Rely on CFF modules instead of raw `google_` resources whenever possible.\n- **Example-based Learning:** Always refer to the module's README (fetched via `scripts/fabric.py`) for correct usage examples.\n- **Formatting & Validation:** Ensure the generated code is properly formatted. If possible, run `terraform fmt`, `terraform validate`, and `terraform plan` to check your work.\n","skills/fabric-builder/references/conventions.md":"# Cloud Foundation Fabric (CFF) Conventions for Module Consumers\n\nWhen generating Terraform code that consumes Cloud Foundation Fabric modules, you MUST adhere to the following conventions:\n\n## 1. Module Preference\n- **Prefer Modules:** Always prefer CFF modules over raw `google_*` resources.\n- **Flat Structure:** Avoid creating wrapper modules or nested module calls (modules calling other modules). Consume CFF modules directly in your root module.\n- **Module Sources:** Ask the user whether they want to use relative local paths or absolute paths referencing the GitHub repository.\n  - Relative local paths: `source = \"./modules/project\"`\n  - GitHub repository paths: `source = \"github.com/GoogleCloudPlatform/cloud-foundation-fabric//modules/project?ref={VERSION}&depth=1\"` (include version ref and `depth=1`).\n  - All modules used must specify the same version tag. By default, unless the user states otherwise, default to using the latest release tag (obtainable via `python3 scripts/fabric.py release`).\n\n## 2. Naming Conventions\n- **Use `prefix`:** For modules that support it (e.g., `project`, `gcs`), using a `prefix` variable is recommended but not mandatory.\n- **Deterministic Naming:** Prefer using structured, deterministic tokens rather than random strings.\n\n## 3. Dependency Management\n- **Output-to-Input:** Pass outputs from one module directly as inputs to another (e.g., `network = module.vpc.name`).\n- **Ordering:** CFF modules encapsulate dependencies (like API activation) in their outputs. Rely on these outputs to ensure correct creation order.\n- **Service Agents:** Use the `service_agents` output from the `project` module when granting IAM roles to Google-managed service accounts.\n- **Implicit Dependencies:** Avoid explicit `depends_on` unless absolutely necessary. Rely on implicit dependencies (passing outputs to inputs) for better readability and maintainability.\n\n## 4. Factories\n- **When to Use Factories:** Use factories when you need to manage a large number of similar resources (e.g., projects, VPCs, firewall rules) without duplicating module blocks. Factories separate configuration data (in YAML files) from Terraform logic.\n- **Main Modules with Factory Support:**\n  - `project-factory`: For bulk creation of projects, folders, and budgets.\n  - `net-vpc-factory`: For bulk creation of VPCs and associated resources.\n  - `net-vpc`: Supports loading subnets and internal ranges from folders via `subnets_folder` and `internal_ranges_folder` keys in `factories_config`.\n  - `net-vpc-firewall`: Supports loading firewall rules from a folder via `rules_folder`.\n  - `organization` and `folder`: Support loading organization policies and custom roles.\n  - `vpc-sc`: Supports loading access levels and service perimeters.\n- **Usage:** Pass the path to the directory containing YAML files to the `factories_config` variable of the respective module.\n\n## 5. Style for Root Modules\n- **File Structure:** Make file structure dependent on size. For small configurations, use a single `main.tf`. For larger configurations, split into multiple files grouped by resource type (e.g., `main.tf` for general elements, `networking.tf`, `compute.tf`, etc.).\n- **Variables & Defaults:**\n  - Define all variables in `variables.tf`, sorted alphabetically.\n  - Set defaults directly in the `default` attribute if a reasonable default exists or if provided by the user.\n  - Avoid creating a `terraform.tfvars` file unless explicitly requested by the user.\n- **Value Handling & Providers:**\n  - **No Hardcoded Values:** Never use hardcoded values for project IDs, folder IDs, or other specific identifiers unless provided by the user. If a value is required, ask the user for it or create a variable.\n  - **Provider Attributes:** Do not configure provider-level attributes like `project`, `region`, or `zone` in provider blocks (`provider \"google\"`, `provider \"google-beta\"`). All attributes must be passed to the modules explicitly in the module calls.\n- **Formatting:** Adhere to standard Terraform formatting and keep line lengths readable. Wrap complex ternaries in parentheses.\n- **No Local Exec:** Never use `local-exec` or similar provisioners to run shell commands. Rely on native Terraform resources and data sources, preferably from official providers (i.e., no third-party providers).\n\n## 5a. Version Constraints\n- **Omit Root Version Constraints:** Do not include version constraints (for Terraform or providers in `required_providers` or `required_version`) in the root module.\n- **Automatic Resolution:** Let Terraform automatically compute and resolve the required provider and Terraform versions based on the constraints defined within the consumed CFF modules.\n\n## 6. Impersonation and Backend Management\n- **Impersonation:** If the user requires service account impersonation, add the `impersonate_service_account` attribute to the `provider \"google\"` and `provider \"google-beta\"` blocks in `providers.tf`.\n- **Remote Backend:** If the user wants to use a remote backend, prefer `gcs`. Put the backend configuration in the `terraform` block inside `providers.tf`. If impersonation is used, also set it for the backend.\n\n**Template for `providers.tf`:**\n```terraform\nterraform {\n  backend \"gcs\" {\n    bucket                      = \"${bucket}\"\n    impersonate_service_account = \"${service_account}\"\n  }\n}\n\nprovider \"google\" {\n  impersonate_service_account = \"${service_account}\"\n}\n\nprovider \"google-beta\" {\n  impersonate_service_account = \"${service_account}\"\n}\n```\n","skills/fabric-importer/COVERAGE.md":"# Import maturity matrix\n\nMaturity ladder — every resource family is at exactly one level:\n\n| Level | Meaning | Exit criterion |\n|---|---|---|\n| **N** — named | Appears in an example manifest; no mapping rules yet | — |\n| **C** — cookbook | Mapping rules + import IDs + module addresses derived from module source; not yet plan-tested live | cookbook section exists |\n| **V** — verified | Clean imports, both gates green, address table confirmed live against real Google Cloud resources | verified across live rounds |\n\nVerification target: everything in the four FAST-stage example\nmanifests at **V**. Maturity decays — every V carries the provider and\nFabric ref it was verified against; re-verify on bumps (see\n\"Regression habit\").\n\n## Matrix\n\n| Family | Module | Level | Status / Notes |\n|---|---|---|---|\n| Org IAM + audit configs | organization | **V** | Seeded audit configs, `${` escaping verified |\n| Org policies (incl. dry-run, parameters, custom constraints) | organization | **V** | Three-stream denominator enumeration verified |\n| Custom roles | organization | **V** | From-scratch and modified roles verified |\n| Org log sinks | organization | **V** | Destinations graduated to typed module references |\n| Folders + folder IAM | folder (per instance) | **V** | Parent chaining and in-place rename verified |\n| Projects, services, project IAM | project (per instance) | **V** | Project services and IAM bindings verified |\n| Service accounts + impersonation IAM | iam-service-account | **V** | Leaf-IAM opt-in verified |\n| VPC networks / subnets / routes | net-vpc | **V** | ForceNew alignment and subnet address mapping verified |\n| Log buckets (sink destinations) | logging-bucket | **V** | Reference graduation verified |\n| GCS buckets | gcs | **V** | State buckets, ForceNew project ID prefix verified |\n| BigQuery datasets | bigquery-dataset | **V** | Datasets, import IDs, `description = null` alignment verified (tables/views/routines unverified) |\n| Pub/Sub topics | pubsub | **V** | Topics, import IDs, sink reference graduation verified (subscriptions/schemas unverified) |\n| DNS zones | dns | **V** | Private zones, VPC reference link verified |\n| DNS policies / response policies | dns | **N** | Named in `examples/import-manifest.fast-networking.yaml`; no mapping rules yet |\n| NCC spokes (linked-VPC) | raw resource — **capability gap**: no Fabric module (`ncc-spoke-ra` is router-appliance only) | **V** (raw) | Upstream-issue candidate, lift with `moved {}` when a module appears |\n| Cloud Routers / NAT | net-cloudnat | **V** | Integrated router + NAT verified |\n| Firewall policies | net-firewall-policy | **C** | Mapping rules documented in cookbook (Hierarchical, Global, and Regional Network policies) |\n| VPN / Interconnect attachments | net-vpn-*, net-vlan-attachment | **C** | Mapping rules documented in cookbook |\n| KMS keyrings / keys + key IAM | kms | **V** | Seeded keyring, key, leaf-IAM on CryptoKey verified; permanent residue documented |\n| Certificate Authority Service | certificate-authority-service | **V** | CA pools: CAI type, import ID, pool address (`google_privateca_ca_pool.default[0]`), `ca_configs = {}` default-CA trap, and `publishing_options` support verified on Fabric ref containing PR #4107 (commit `a153861aae`, released in `v58.0.0`). Note: on releases lacking #4107 (`v57.0.0` and earlier), CAS reverts to capability gap |\n| VPC-SC policy / perimeters | vpc-sc | **V** | Live AccessPolicy adopted, AccessLevel, dry-run ServicePerimeter; bare-numeric policy import ID verified; `unknown`-level classification handled in `inventory.py`. Enforced perimeters (`status` block) unverified |\n| Tags (keys/values/bindings) | organization / folder / project tags | **V** | TagKey, TagValue, TagBinding, import IDs, address shapes, verified at organization, folder and project level. The `templatestring` escaping matrix (`$$${`) is DERIVED FROM MODULE SOURCE, not exercised — no seeded value contained `${` (tag IAM unverified) |\n| Billing account IAM | billing-account | **N** | — |\n| PAM entitlements | organization / folder / project `pam_entitlements` factory | **N** | Entitlements are configuration and importable. PAM **grant bindings** never enter the denominator: `inventory.py` sweeps active grants via CAI (`privilegedaccessmanager.googleapis.com/Grant`) whenever IAM is collected and strips matching bindings deterministically (target, role, requester from the grant itself), stamping them in `_meta.pam_grant_exclusions`. Structural exemption, not a waiver; declaring the Grant type in a manifest is refused. Strip logic unit-tested; not yet exercised against a live grant |\n| IAM deny policies | organization / folder / project | **N** | Not a CAI asset type; enumerated automatically by the built-in `gcloud iam policies list --kind=denypolicies` enumerator. Command shape verified, payload shape not yet exercised live |\n| Log exclusions | organization / folder / project | **N** | Neither a CAI asset type nor a gcloud command group; needs REST enumeration recorded in the run report |\n| Workload identity pools / providers | project identity-providers | **V** | WorkloadIdentityPool, WorkloadIdentityPoolProvider, import IDs, address shapes, 30-day soft-delete residue, and org policy constraints verified |\n| Anything else | — | fallback | documented raw-resource fallback + capability-gap report |\n\n## Verification Notes & Caveats\n\n- **Additive IAM default**: all V-level IAM rows above were verified\n  under **authoritative** emission (`iam` / `iam_bindings` maps), which\n  is now the opt-in (`emission.iam: authoritative`). The additive\n  default (`iam_bindings_additive` → `google_*_iam_member.bindings`)\n  is at **C**: addresses and import-ID formats derived from module\n  source and provider docs, not yet exercised live. First verified\n  additive round graduates it and re-stamps.\n- **Factory emission (opt-in)**: all matrix levels above are for the\n  default per-instance emission. The `factory` emission mode (manifest\n  `emission:` block; cookbook \"Factory emission (opt-in)\") is at **C**\n  across all carriers — address shapes and layout rules derived from\n  module source in-repo, no live import exercised yet. First verified\n  run graduates the exercised carrier and stamps it here.\n\n- **CAS & Custom Roles**: CAS `publishing_options` requires Fabric PR #4107 (`a153861aae`). Custom roles title/description convergence requires Fabric PR #4102. Both are released in `v58.0.0`.\n- **Non-CAI types**: CAI is the default source of the denominator, not its boundary. Types it does not model are enumerated with `gcloud` — automatically where `inventory.py` ships a built-in enumerator, otherwise via a manifest `enumerate:` block — or, where gcloud has no container-scoped surface, out of band against the REST API. All of it merges into the same denominator, and `inventory.py` stops the run rather than proceeding without a type it cannot enumerate. See `references/cai-blind-spots.md`.\n- **Provider Label Inheritance**: Provider v5/v6/v7 generates computed `terraform_labels: {} -> {...}` diffs for resources with inherited provider labels. These are accounted for via scoped rules in `scripts/benign-drift.yaml`.\n\n## Regression habit\n\nOn every provider minor bump, Fabric ref bump, or after long dormancy:\nre-run both gates on the persisted workspace. New provider artifacts\nfail loudly as residuals; resolve via value-guarded benign rules in\n`scripts/benign-drift.yaml`, then re-stamp `verified_against`. Never widen\na rule to make a bump pass.\n","skills/fabric-importer/README.md":"# fabric-importer\n\n`fabric-importer` reverse-engineers an existing Google Cloud organization into Cloud Foundation Fabric Terraform modules with native `import` blocks. An AI agent generates the module mapping; a set of frozen verification scripts proves the result is complete, attribute-accurate, and plan-converged. It never modifies anything in your live cloud environment.\n\nStart with the document for your role:\n\n- **Human Operator / Requesting an Import**: Follow this guide below.\n- **AI Agent Performing the Work**: Follow [SKILL.md](./SKILL.md).\n- **Supported Resources & Maturity**: See the [Maturity Matrix](./COVERAGE.md).\n\n---\n\n## The Design in One Paragraph\n\nMost infrastructure importers rely on brittle per-resource generator pipelines that quickly grow stale. This skill inverts that model: the AI agent inspects the live inventory and Fabric module sources directly, then emits idiomatic Fabric module calls paired with native Terraform 1.5+ `import {}` blocks. Two frozen verification gates validate the output. This is sound because every mistake is immediately visible: a missing attribute or schema mismatch causes `terraform plan` to propose unexpected changes, and a missed resource is flagged during inventory reconciliation. Verification per run replaces trust in generator code.\n\n---\n\n## What you provide, and what the tools work out\n\nYou write one file: `import-manifest.yaml`. Everything else is derived from it.\n\nThe important part is what that file contains. **It names resource types, not\nresources.** You never write a list of your 47 org policies. You write:\n\n```yaml\nscopes:\n  - root: organizations/123456789012\n    types:\n      - type: org-policy\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n```\n\nThat means \"in this organization, take over org policies attached at org level,\nand every folder\". Finding the actual 47 policies and 12 folders is the tool's\njob — and it has to be. If a human wrote the list of resources, \"everything is\ncovered\" would only ever mean \"everything I remembered to write down\". The\nmanifest is a deliberately coarse statement of intent; turning intent into an\nexact list is where people miss things, so a frozen script does it and refuses\nto hand back a partial answer.\n\nThe run answers five questions in order, and only the third one is yours:\n\n| Question | Answered by | Binding? |\n|---|---|---|\n| What do I already manage in Terraform? | `manifest_from_state.py` | no, advisory |\n| What actually exists in the cloud? | `inventory.py survey` + `manifest_init.py` | no, advisory |\n| **What do I want under management?** | **you, in `import-manifest.yaml`** | **yes** |\n| Which live resources does that mean, exactly? | `inventory.py collect` → `inventory.json` | yes |\n| How much of it is done? | `coverage.py` | yes |\n\nThe first two exist to break a chicken-and-egg problem: you cannot write a\nsensible manifest about an estate you cannot see, and the tools that measure the\nestate need a manifest first. Neither is a gate, neither produces anything\nbinding, and you can skip both if you already know your scope. Run them anyway\nthe first time: the gap between \"what Terraform manages\" and \"what exists\" is\nusually the most useful thing discovered all week.\n\n### Different depth per subtree: per-scope `types:`\n\nOne estate rarely wants the same depth everywhere — governance at org and\nfolder level, the full connectivity stack in the networking folder, two named\nworkload projects and nothing else. `scopes:` is a list, and **every scope\ncarries its own `types:` list**; there is no other place to declare types, so\nwhat a scope collects is exactly what is written on it:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: org-policy\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: workloads\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111\n    types:\n      - type: iam\n        levels: [project]\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n```\n\nThe same type may appear in several scopes with different `levels`, `iam` or\n`enumerate`. Because a type list decides the denominator, the rules fail the\nrun with a message rather than warning — before any API call, exit 1:\n\n| Rule | Why |\n|---|---|\n| Every scope declares `types:`; `types: []` is rejected | A scope that collects nothing passes every check by not collecting |\n| A type whose `levels` cannot intersect its scope's `levels` is rejected | A dead declaration reads like coverage and produces none (an entry listing `unknown` is exempt) |\n| A type declared twice in one list is rejected | Last-wins would quietly narrow levels |\n| The retired grammar (top-level `scope:` / `types:`) is rejected with migration instructions | Half-accepting it would change what the denominator means — see [references/manifest-migration.md](./references/manifest-migration.md) |\n\n`examples/import-manifest.multi-domain.yaml` is a worked four-scope manifest;\n`examples/import-manifest.org-foundation.yaml` is the single-scope reference.\n\nAfterwards, read the **per-scope** tables in `inventory.json`:\n`_meta.scopes[].declared_types` and `_meta.scopes[].zero_yield_types`.\n`_meta.declared_types` is the aggregate across scopes, and the aggregate can\nhide a scope whose own declaration yielded nothing — a type non-zero org-wide\nand zero in the one scope you cared about. Every inventory entry (and\ntherefore every worklist entry) also names the scope(s) that collected it in\nits `scopes` field.\n\n### `survey` and `collect` are not the same thing\n\nThey are the same script and both list resources, which makes them easy to\nconfuse. Their purposes are opposite.\n\n`survey` runs **without** a manifest. It browses, so that a human can decide. Its\noutput is a conversation aid and is never used to judge anything.\n\n`collect` runs **only** with an approved manifest. Its output is the\n**denominator**: the exact list of live resources matching what you agreed.\nEverything downstream is measured against it.\n\n### The two gates answer different questions\n\nThis is the part worth reading twice, because neither gate can catch the other's\nfailure.\n\n| Gate | Compares | Answers |\n|---|---|---|\n| 1 — `coverage.py` | `inventory.json` against the **text of your code** | did you cover everything? |\n| 2 — `verify_plan.py` | the **Terraform plan** against reality | is what you wrote correct? |\n\nGate 1 never runs Terraform and never contacts your cloud. It reads\n`inventory.json`, the `import {}` blocks it parses out of your `*.tf` files, the\n`coverage-map.yaml` ledger, and `waivers.yaml`. Every asset in the denominator\nmust be in the ledger or in the waivers.\n\nGate 1 can pass on completely wrong code — right addresses, garbage\nconfiguration. The plan catches that, as diffs.\n\nGate 2 can pass on a workspace covering 3 of your 59 resources. A plan of three\nclean imports and nothing else is perfectly converged; Terraform has no idea the\nother 56 exist. **A converged plan says nothing about completeness**, because\nTerraform only knows about what you wrote down. Completeness can only be judged\nagainst a list built independently of the code, which is exactly what\n`inventory.json` is.\n\n### `inventory.json` is the only door\n\nThe loop is closed in both directions:\n\n- a ledger key that is not in the inventory fails gate 1 as `stale`\n- an `import {}` block that no ledger key claims fails gate 1 as an `orphan`\n\nSo nothing can be imported that the denominator does not contain, and nothing in\nthe denominator can be quietly dropped. That is what makes \"both gates green\"\nmean something precise: everything the manifest declared is either in code or\nwaived by name, and applying that code would change nothing.\n\n---\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where human judgment is required (e.g. narrowing scope, waiving assets, or accepting provider drift). Keep mechanical, reversible steps (asset collection, scaffolding, linting, plan checks) autonomous. Gates are **blocking**: if running non-interactively and confirmation cannot be obtained, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Scope Approval** | Manifest drafting, before any enumeration | Reviews and commits `import-manifest.yaml`: which resource types each scope declares, at which hierarchy levels (org/folder/project), under which included/excluded subtrees. |\n| **Waiver Signing** | Completeness Gate (`coverage.py`) | Reviews deliberate exclusions in `waivers.yaml` and signs them with attribution (`signed_by`) for unmanaged or auto-generated resources (e.g. default log sinks, default compute service accounts). |\n| **Benign Drift Review** | Plan Convergence Gate (`verify_plan.py`) | Evaluates proposed cosmetic provider quirks (e.g. computed labels, default timeouts) and commits reviewed entries to `scripts/benign-drift.yaml`. |\n| **Final Review & Apply** | Handover, before `terraform apply` | Inspects the final zero-drift plan, run report, and input provenance digests before running `terraform apply` on their own schedule. |\n\n---\n\n## How the work flows\n\nThree views. The main loop, then the two places where a single item funnels\nthrough a decision — enumeration and mapping.\n\n### The main loop\n\n```mermaid\nflowchart TD\n    A[\"Existing .tfstate files<br/>or the live estate\"] --> B[\"Draft import-manifest.yaml<br/>manifest_from_state.py, or<br/>inventory.py survey + manifest_init.py\"]\n    B --> G0{{\"HUMAN GATE 1<br/>approve the scope\"}}\n    G0 --> C[\"inventory.py collect\"]\n    C --> D[(\"inventory.json<br/>THE DENOMINATOR\")]\n    D --> E[\"coverage.py --worklist-out\"]\n    E --> F[\"AGENT WORK<br/>module config + import blocks<br/>+ coverage-map.yaml\"]\n    F --> G1{\"GATE 1 machine<br/>coverage.py\"}\n    G1 -- \"missing assets\" --> W{{\"HUMAN GATE 2<br/>sign a waiver\"}}\n    W --> G1\n    G1 -- \"gaps to close\" --> F\n    G1 -- \"reconciled, exit 0\" --> P[\"terraform plan\"]\n    P --> G2{\"GATE 2 machine<br/>verify_plan.py\"}\n    G2 -- \"residual diff\" --> F\n    G2 -- \"provider quirk?\" --> BD{{\"HUMAN GATE 3<br/>accept benign drift\"}}\n    BD --> G2\n    G2 -- \"converged, exit 0\" --> R[\"Run report\"]\n    R --> NEXT[\"Widen the manifest<br/>for the next round\"]\n    NEXT --> G0\n    R --> G3{{\"HUMAN GATE 4<br/>apply sign-off\"}}\n    G3 --> DONE([\"terraform apply<br/>run by the operator\"])\n```\n\nEverything above the scope gate is advisory. Everything below it is mechanical\nand binding.\n\n### Funnel 1: how a declared type becomes inventory entries\n\n```mermaid\nflowchart TD\n    T[\"One type declared in a<br/>scope's types: list\"] --> Q1{\"pseudo-type?\"}\n    Q1 -- \"iam\" --> S1[\"asset list<br/>--content-type=iam-policy\"]\n    Q1 -- \"org-policy\" --> S2[\"three streams merged:<br/>org-policy content type<br/>+ orgpolicy Policy assets<br/>+ gcloud org-policies list<br/>per container\"]\n    Q1 -- \"no, a real asset type\" --> Q2{\"enumerator registered?<br/>built-in table, or a<br/>manifest enumerate: block\"}\n    Q2 -- \"yes\" --> S3[\"gcloud list per container<br/>CAI never consulted\"]\n    Q2 -- \"no\" --> S4[\"gcloud asset list<br/>batched with the other types\"]\n    S4 --> Q3{\"outcome\"}\n    Q3 -- \"ok\" --> OUT\n    Q3 -- \"type not in the<br/>CAI catalogue\" --> STOP[\"STOP, exit 3<br/>fix the type string,<br/>add an enumerator,<br/>or drop it deliberately\"]\n    Q3 -- \"any other error<br/>403, ACM types\" --> S5[\"asset search-all-resources\"]\n    S5 -- \"ok\" --> OUT\n    S5 -- \"still failing\" --> STOP2[\"STOP, exit 3<br/>partial denominator refused\"]\n    S1 --> OUT[(\"inventory.json\")]\n    S2 --> OUT\n    S3 --> OUT\n```\n\nNo path exits with \"skip it\". Every branch ends in the denominator or in a stop,\nbecause an unenumerated asset is invisible to both gates at once — it would pass\nevery check by not existing. The funnel runs once per scope: the same type can\nenter it from two scopes' lists with different levels, and a type declared in\nonly one scope is swept only there.\n\n### Funnel 2: how a worklist item becomes code\n\n```mermaid\nflowchart TD\n    I[\"Worklist item\"] --> M{\"Does the canonical<br/>Fabric module express<br/>the live resource?\"}\n    M -- \"yes\" --> A[\"Module config<br/>+ import block<br/>+ coverage-map entry\"]\n    M -- \"module exists but cannot<br/>express an attribute\" --> B[\"Raw google_* resource<br/>+ MODULE CAPABILITY GAP<br/>in the report\"]\n    M -- \"no module covers<br/>the type at all\" --> C[\"Raw google_* resource<br/>+ report, propose upstream\"]\n    M -- \"deliberately out of scope\" --> D{{\"HUMAN GATE<br/>signed waiver\"}}\n    M -- \"a residual you<br/>cannot explain\" --> STOP[\"STOP<br/>report it, never rationalise it\"]\n    A --> P[\"Both gates\"]\n    B --> P\n    C --> P\n    D --> X[\"Not in the workspace,<br/>named in the report\"]\n```\n\nThree of those four outcomes are successes. That matters: the temptation at the\nmiddle two is to force the module to fit, or to call the leftover diff benign.\n\n---\n## Step-by-Step Operator Guide\n\n### 1. Declare the Scope (`import-manifest.yaml`)\nYou and the agent agree on an `import-manifest.yaml` declaring, per scope, which resource types to import (e.g., folders, service accounts, VPC networks, org policies), at which container levels (organization, folder, or project), and which subtrees to include or exclude — see [Different depth per subtree](#different-depth-per-subtree-per-scope-types).\n\nThis is the only file you author, and it names types, not resources. The two\noptions below are drafting aids that answer different questions — A tells you\nwhat Terraform already manages, B tells you what exists. Run both the first\ntime and compare them; run neither if you already know your scope. Neither\noutput is binding until you have reviewed it and committed it.\n\n**Option A — Inferred from existing Terraform state(s):**\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n```\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate:**\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json --scope organizations/ORG_ID --out import-manifest.yaml\n```\nReady-made examples aligned with FAST stages are provided in\n[`examples/`](./examples/); `import-manifest.org-foundation.yaml` is the\nsingle-scope reference and `import-manifest.multi-domain.yaml` the per-scope\nmulti-domain one.\n\n### 2. Enumerate the Denominator (`inventory.json`)\nThe enumeration script queries Cloud Asset Inventory (CAI) and service APIs to construct the exact denominator of all matching live assets:\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nCAI is the default source of the denominator, not its boundary. CAI does\nnot model every GCP resource, and a type it cannot see would otherwise be\ninvisible to both gates at once — so the tool routes around it rather\nthan shrinking the denominator: built-in `gcloud` enumerators for types\nknown to be absent from the catalogue (declaring the type is all it\ntakes), automatic sibling sweeps for the Compute families whose primary\nCAI surface splits them into separate scoped types (accounted under the\ndeclared type, real type preserved in `_meta.split_type_sweeps`), and a\nhard stop with the remedy where no enumerator exists — either the type\nstring is wrong (checked against the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types)),\nor the manifest needs a native `enumerate:` block: a read-only `gcloud`\ncommand run per in-scope container, normalized into the same inventory,\ndeclared in the `types:` list of each scope that needs it (per-scope\nlists never inherit). The full ladder (CAI → gcloud → REST API → signed\nwaiver), the enumerator syntax and guard rails, and the split-taxonomy\nmechanics are in\n[references/cai-blind-spots.md](./references/cai-blind-spots.md).\n\nThe split-type table is a frozen snapshot of a document Google changes,\nso check it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nIt costs one extra `search-all-resources` call per scope and fails the\nrun if the search surface returns an asset the list sweep did not.\n\nThe run stays auditable without becoming unreadable: every run closes\nwith a one-line cost summary on stderr (`7 gcloud call(s) in 12.0s:\nasset list x5, org-policies list x2`), `--verbose` (after the\nsubcommand) prints each command as it runs with outcome, duration and\nitem count, the full log is written to `_meta.api_calls`, and every\nnon-CAI sweep is recorded verbatim in `_meta.native_sweeps` so a\nreviewer can re-run it.\n\nProvenance is per scope: each `_meta.scopes[]` entry records its own\n`declared_types` yield counts and `zero_yield_types`, and every\ninventory entry names the scope(s) that collected it.\n`_meta.declared_types` is the aggregate — read the per-scope counts\ntoo, because a type that yields zero in one scope and non-zero in\nanother does not show up in the aggregate zero-yield warning (the\nper-scope warning on stderr does).\n\n### 3. Compute Delta Worklist\nThe completeness tool reconciles the denominator against any existing code and written waivers. This is the same program that runs as gate 1 in step 5 — the same comparison, run for a different reason. Here it tells you what is left to do; there it decides whether anything is. On the first round everything is outstanding, so the worklist is the whole denominator. On later rounds it is only the delta, which is what makes repeated rounds cheap:\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\n### 4. Agent Maps & Emits Terraform\nFor each item in `worklist.yaml`, the agent writes:\n1. Canonical Fabric module configuration matching the live state.\n2. A paired Terraform `import {}` block:\n   ```hcl\n   import {\n     to = module.folder-team-a.google_folder.folder[0]\n     id = \"folders/123456789012\"\n   }\n   ```\n3. A ledger entry in `coverage-map.yaml` linking the asset to its Terraform address.\n\n### 5. Automated Verification (Definition of Done)\nWork is complete only when two independent gates pass with exit code `0`. They are independent in a specific sense: each catches a failure the other cannot see. Gate 1 can pass on wrong code, and gate 2 can pass on a workspace covering three resources out of fifty-nine. See [The two gates answer different questions](#the-two-gates-answer-different-questions).\n- **Completeness Gate (`coverage.py`)**: Reconciles the inventory against `coverage-map.yaml`, `waivers.yaml`, and emitted `import {}` blocks. Every asset must be mapped or waived. It reads files only — no Terraform, no cloud access — so it needs neither credentials nor a `terraform init` to run.\n- **Plan Convergence Gate (`verify_plan.py`)**: Consumes `terraform show -json <planfile>` output (file argument or stdin) and classifies the planned actions. It never invokes Terraform itself. The plan may only contain clean imports, no-ops, and human-reviewed benign drift entries from `scripts/benign-drift.yaml`. Any residual attribute modification or destruction fails the gate.\n\nRun both gates like this:\n\n```bash\nterraform -chdir=tf fmt -recursive\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # a stale plan must never answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` to the plan: it returns 2 for any\nnon-empty plan, and a converged import plan is always non-empty.\n`verify_plan.py` is the verdict.\n\n### 6. Review and Apply\nThe resulting workspace (`tf/`) is clean, formatted, and ready for you to review and run `terraform apply` at your own pace. The skill **never** runs `terraform apply` or mutates live cloud resources.\n\n---\n\n## Script reference\n\nRun every script from the skill directory. All output paths are relative\nto the current working directory.\n\n| Script | Purpose | Flags |\n|---|---|---|\n| `inventory.py survey` | Enumerate everything in scope, to draft a manifest from | `--scope` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants` |\n| `manifest_init.py` | Draft a manifest from a survey (Mode B) | `--survey` (required), `--scope` (required), `--out` |\n| `manifest_from_state.py` | Infer a manifest from existing `.tfstate` (Mode A) | `--state` (1+, required), `--out` (`-` for stdout), `--force` to overwrite an existing manifest |\n| `inventory.py collect` | Build the denominator from CAI | `--manifest` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants`, `--verify-search-parity` |\n| `coverage.py` | Gate 1 — completeness | `--inventory` (required), `--workspace` (required), `--coverage-map`, `--waivers`, `--require-signed-waivers`, `--allow-empty-inventory`, `--worklist-out` |\n| `verify_plan.py` | Gate 2 — plan convergence | positional plan JSON (default stdin), `--rules`, `--allow-empty-plan` |\n| `integrity.py` | Print the frozen-tools provenance digest | `--verbose` for per-file digests |\n\n`inventory.py` requires the `survey` or `collect` subcommand; it has no\ntop-level flags. `--verbose` therefore goes after the subcommand\n(`inventory.py collect --manifest m.yaml --verbose`), not before it.\n\nExit codes — `coverage.py`: `0` reconciled, `1` malformed input, `2` gaps\nor problems. `verify_plan.py`: `0` converged, `1` malformed input, `2`\nresidual changes, `3` converged but ADVISORY (a substituted `--rules`\nfile). A substituted ruleset can never exit `0`; a residual plan still\nexits `2`. `inventory.py`: `0` ok, `1` a manifest the validator refuses\n(retired grammar, missing/empty per-scope `types:`, duplicate type,\ninvalid level, dead per-scope declaration — printed with the reason\nbefore any API call), `3` a partial denominator (unsupported type,\ntolerated sweep failure, split-parity gap). Note that argparse usage\nerrors also exit `2`, so read the message, not only the code.\n\nThere is no checked-in expected digest. To check a recorded gate run,\ncompute `uv run scripts/integrity.py` from a pristine checkout of the\nsame commit and compare it with the `frozen tools:` line in the captured\noutput.\n\n---\n\n## Prerequisites & Permissions\n\n### System Requirements\n- **Terraform**: `v1.5.0+` (required for native `import {}` blocks).\n- **Google Cloud SDK (`gcloud`)**: Authenticated with a configured quota project.\n- **[`uv`](https://docs.astral.sh/uv/)**: the recommended way to run the scripts.\n  Every script declares its own dependencies inline\n  ([PEP 723](https://peps.python.org/pep-0723/), the same convention as\n  `tools/tfdoc.py`), so `uv run scripts/<name>.py` resolves them per script with\n  nothing to install and nothing to activate.\n- **Python**: `3.10+` if you would rather not use `uv`. Then run the scripts with\n  `python3` and install `PyYAML` yourself; the commands are otherwise identical.\n\n### Minimal Read-Only IAM Permissions\nRun the import using a dedicated read-only identity. The following roles on the organization scope provide full read visibility:\n\n| Role | Purpose |\n|---|---|\n| `roles/cloudasset.viewer` | CAI asset inventory enumeration across the hierarchy |\n| `roles/resourcemanager.organizationViewer` | Organization resource details |\n| `roles/resourcemanager.folderViewer` | Folder hierarchy discovery |\n| `roles/viewer` | Read-only inspection of imported resources via service APIs |\n| `roles/iam.securityReviewer` | IAM policy discovery at organization, folder, and project levels |\n| `roles/orgpolicy.policyViewer` | Organization policy evaluation |\n| `roles/accesscontextmanager.policyReader` | VPC Service Controls discovery (when VPC-SC is in scope) |\n\nNo write or admin permissions are required.\n\n---\n\n## User Ownership Boundary\n\nThree human-owned files govern the process. The agent may propose edits, but only you commit them:\n\n| File | Purpose |\n|---|---|\n| `import-manifest.yaml` | Declares the scopes, each with its own resource-type list, container levels, and subtree filters. |\n| `waivers.yaml` | Written waivers for resources deliberately excluded from management (e.g. `_Default` log sinks, default compute service accounts). Requires a `signed_by` attribute. |\n| `scripts/benign-drift.yaml` | Scoped provider quirks accepted as cosmetic diffs (e.g., computed provider labels or default timeouts). |\n| `references/address-map.yaml` | The fielded subset of the mapping cookbook: asset type, module, address pattern, import ID, verification round. Advisory, not a gate input — see below. |\n\n---\n\n## Repository Layout\n\n```text\nskills/fabric-importer/\n├── SKILL.md                     # Agent protocol and operational contract\n├── README.md                    # Human operator guide and architecture overview\n├── COVERAGE.md                  # Resource family maturity matrix (N / C / V)\n├── scripts/                     # Verification gates and helper utilities\n│   ├── inventory.py             #   Asset enumeration (CAI + API sweeps)\n│   ├── coverage.py              #   Completeness gate & delta worklist generator\n│   ├── verify_plan.py           #   Plan-convergence gate (benign-drift aware)\n│   ├── manifest_from_state.py   #   Terraform state-driven manifest inference\n│   ├── manifest_init.py         #   Starter manifest drafting assistant\n│   ├── integrity.py             #   Input binding & runtime provenance stamping\n│   ├── address_map.py           #   Address-map validator, cookbook linter, table renderer\n│   └── benign-drift.yaml        #   Human-reviewed provider quirk ruleset\n├── examples/                    # Manifest and waiver templates\n│   ├── import-manifest.org-foundation.yaml\n│   ├── import-manifest.multi-domain.yaml\n│   ├── import-manifest.fast-org-setup.yaml\n│   ├── import-manifest.fast-security.yaml\n│   ├── import-manifest.fast-vpcsc.yaml\n│   ├── import-manifest.fast-networking.yaml\n│   └── waivers.example.yaml\n├── references/                  # Normative mapping rules & technical gotchas\n│   ├── mapping-cookbook.md      #   Traps, reasoning, capability gaps (prose)\n│   ├── address-map.yaml         #   Addresses, import IDs, asset types (data)\n│   ├── inferring-manifests-from-state.md # State-driven manifest inference workflow\n│   ├── manifest-migration.md    #   Migrating manifests from the retired grammar\n│   ├── cai-blind-spots.md       #   Known CAI gaps and service API mitigations\n│   └── operating-contract.md    #   Safety invariants and trust boundaries\n└── tests/                       # Unit test suite\n```\n\n### Where mapping knowledge lives\n\nThe cookbook carries two different kinds of knowledge, and they are now\nstored differently because they fail differently.\n\n**Fielded tuples** — asset type, module, address pattern, import ID,\nverification round — live in `references/address-map.yaml`. They are\nuniform and mechanically checkable, and they were the part that kept\nbeing duplicated: the same address appeared in per-type prose and again\nin a hand-maintained markdown table, and three families were once\ndocumented twice because nothing could detect it. Two invariants are now\nenforced:\n\n- an address pattern is claimed by exactly one entry;\n- a CAI asset type serviced by more than one module (the live case:\n  `compute.googleapis.com/Router`, created by both `net-cloudnat` and\n  `net-vpn-ha`) must say how to tell the carriers apart.\n\n**Reasoning** — ForceNew traps, provider quirks, waiver postures,\ncapability gaps — stays in `references/mapping-cookbook.md`, in prose,\non purpose. It is causal and only degrades when squeezed into a schema.\n\n```bash\nuv run scripts/address_map.py                      # validate + lint\nuv run scripts/address_map.py --render-cookbook    # regenerate the table\nuv run scripts/address_map.py --check-workspace tf/  # advisory, always exits 0\n```\n\n`address_map.py` is a linter, **not a gate**. `coverage.py` and\n`verify_plan.py` never read the address map, and a test enforces that.\nA wrong entry is caught by `terraform plan` erroring loudly, unlike a\nwrong `benign-drift.yaml` entry which would silently pass a gate. The\nmap is also deliberately incomplete: an address it does not recognise is\na type nobody has mapped yet, which SKILL.md calls the normal case.\n","skills/fabric-importer/SKILL.md":"---\nname: fabric-importer\ndescription: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n---\n\n# Fabric Importer — agent protocol\n\nYou (the agent) reverse-engineer live GCP resources into Cloud Foundation\nFabric Terraform. There is no per-resource pipeline: **you perform the\nmapping yourself**, guided by the cookbook, and two frozen gates verify\nyour output. Trust comes from the gates, not from you.\n\n## Trust boundary (non-negotiable)\n\n- **Frozen scripts** — everything in `scripts/`, rulesets included\n  (`benign-drift.yaml`). You may RUN them; you must NEVER modify them.\n  The helpers are frozen for the same reason as the gates: they decide\n  the SCOPE the denominator is built from, so editing one shrinks what\n  \"complete\" means. Both gates print runtime provenance and active input hashes;\n  a reviewer compares them against a clean checkout. If a gate is\n  genuinely broken, report it with evidence — do not patch around it.\n- **Human-owned files**: the import manifest and the waiver ledger. You\n  may draft or propose changes; a human reviews and commits them.\n- **Yours**: everything in the workspace (`tf/`, `coverage-map.yaml`,\n  reports). Constraint: once an address exists, it is immutable — extend,\n  never rewrite (see \"Incremental runs\").\n\n## Safety contract\n\n1. **NEVER run `terraform apply`.** The workspace contains authoritative\n   surfaces (org policies, sinks — and authoritative IAM where opted\n   into), so an apply is equivalent to overwriting live config with a\n   snapshot. The additive-IAM default narrows that blast radius; it does\n   not lift this rule. Verification is plan-only via `verify_plan.py`.\n   Never synthesize state files.\n2. **Read-only against GCP**: `list`/`describe`/`get-iam-policy`/asset\n   export only. Prefer a dedicated read-only identity\n   (`roles/cloudasset.viewer` + per-service viewers, via impersonation)\n   so an accidental apply fails with 403.\n3. **All workspace output is org-confidential.** Fabric is used\n   fork-and-own, so during the OWN phase these artifacts legitimately\n   live in the operator's own private fork alongside the code — that is\n   the expected working directory. What must never happen is any of it\n   reaching a PUBLIC repository, an upstream pull request, or a document\n   that leaves the engagement: no org/folder/project ids, principals,\n   resource names or asset counts. Pulled `.tfstate` and saved\n   `*.tfplan` additionally contain secret VALUES (Secret Manager\n   payloads, generated keys); delete them once the manifest is drafted\n   and the plan is verified, and never commit them anywhere.\n4. **Never rationalize a residual diff.** If you believe a plan diff is a\n   provider artifact, propose a `benign-drift.yaml` entry with evidence in\n   your report and stop. A human accepts it or the run stays red.\n\n## Human-in-the-Loop Gates\n\nFour decisions are human-owned and BLOCKING: scope approval (the\nmanifest), waiver signing (`signed_by` in `waivers.yaml`), benign-drift\nacceptance (`scripts/benign-drift.yaml`), and the final apply sign-off.\nEach is called out at its step below; the README's gate table is the\noperator-facing contract. Keep mechanical, reversible steps (asset\ncollection, scaffolding, linting, plan checks) autonomous. If running\nnon-interactively and confirmation cannot be obtained, stop — never\nassume approval.\n\n## Operational Workflow\n\n### Step 0 — establish the manifest (with the user)\n\nThe manifest is the user's contract: which resource types, at which\nlevels, under which subtrees — declared per scope, since `scopes:` is\na list and **every scope carries its own `types:` list**. Never invent\nit silently. If one exists, confirm it; otherwise run one of the\ndrafting workflows:\n\n**Option A — Inferred from existing Terraform state(s)** (preferred when migrating existing TF):\n\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n# add --force to replace an existing manifest, or --out - to review first\n```\n\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate**:\n\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json \\\n  --scope organizations/ORG_ID --out import-manifest.yaml\n```\n\nThen interview the user over the draft (every discovered type is listed\nwith per-level counts, commented out; foundation types are pre-enabled):\n\n- What do you want under Terraform management *now*? (Start narrow —\n  org foundation first, workloads later. Show them\n  `examples/import-manifest.org-foundation.yaml` as the reference.)\n- At which levels? (e.g. org IAM yes, project IAM no.)\n- Same depth everywhere, or different depths per subtree? (Different ⇒\n  several scopes, each with its own `types:` list —\n  `examples/import-manifest.multi-domain.yaml` is the reference.)\n- Any subtrees to exclude (sandboxes, decommissioning trees)?\n\nWhen writing a scope's `include`/`exclude`, note that CAI `ancestors`\nstore NUMERIC project numbers; `inventory.py` automatically resolves\nalphanumeric project IDs to numbers during enumeration, but project numbers\nremain supported and canonical.\n\n**Per-scope `types:` rules (fail-closed, exit 1 before any API call).**\nA scope's list is the only place types are declared, so what a scope\ncollects is exactly what is written on it. The same type may appear in\nseveral scopes with different `levels`, `iam` or `enumerate`. The\nvalidator refuses: a scope without a `types:` list or with `types: []`\n(a scope that collects nothing may not say so quietly); a type whose\n`levels` cannot intersect its scope's `levels` (dead declaration —\nentries listing `unknown` are exempt); a duplicate `type:` within one\nlist; and the retired top-level `scope:`/`types:` grammar, which is\nrejected with migration instructions — a manifest from an earlier\nengagement is migrated per\n[references/manifest-migration.md](./references/manifest-migration.md).\nUnlike `types:`, a scope's\n`emission:` map inherits built-in defaults for omitted families — it\nis denominator-neutral; do not reason from one knob to the other.\n\nThe user commits the manifest. Scope changes later = manifest edit +\nincremental re-run, never a rewrite.\n\n### Step 1 — enumerate (the denominator)\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nBy default, `inventory.py survey` and `inventory.py collect` filter out\nsoft-deleted containers (`DELETE_REQUESTED` / `DELETE_IN_PROGRESS` folders and\nprojects) and their child resources, matching what is active in the GCP Console.\nPass `--include-deleted` if you explicitly need to capture soft-deleted assets in\nthe denominator (e.g. to audit or waive them).\n\nSimilarly, resources GCP creates or manages on the operator's behalf —\nun-creatable auto-generated VPC routes (subnet-local routes, NCC and peering routes),\nGoogle-managed default log sinks and log buckets (`_Default` and `_Required`),\nand ephemeral Privileged Access Manager (PAM) grants (`privilegedaccessmanager.googleapis.com/Grant`) —\nare automatically excluded from the denominator. Pass `--include-auto-generated`\n(or `--include-auto-generated=family,...` e.g. `routes`, `logging-defaults`, `pam-grants`;\n`--include-logging-defaults` and `--include-pam-grants` remain supported as aliases)\nif you explicitly need to capture them in the denominator.\n\n**CAI is the default source of the denominator, never the boundary of\nit.** Cloud Asset Inventory does not model every GCP resource. A type it\ndoes not support must be enumerated by other means and merged into the\nsame denominator — never dropped: an unenumerated asset is invisible to\nBOTH gates at once, which is the exact failure the gates exist to\nprevent. `inventory.py` handles the known cases itself: built-in gcloud\nenumerators for types absent from the CAI catalogue\n(`NATIVE_ENUMERATORS`), automatic sibling sweeps for the Compute\nfamilies whose list surface splits them into separate scoped types\n(stamped in `_meta.split_type_sweeps`, per entry as `cai_list_type`),\nand a hard stop — separated from permission failures — where no\nenumerator exists. When a sweep stops, or a declared type yields less\nthan you expected, read\n[references/cai-blind-spots.md](./references/cai-blind-spots.md) BEFORE\nworking around it: it holds the full remedy ladder (wrong type string →\nsplit CAI taxonomy → native `enumerate:` block, declared in the `types:`\nlist of each scope that needs it, since per-scope lists never inherit →\nREST API), the guard rails for enumerator blocks, and the built-in\ntable. The manifest is human-owned: you draft an enumerator block, a\nhuman commits it; one that worked belongs in your report as a proposed\naddition to the built-in table.\n\nThe split-type table is a frozen snapshot of a doc that changes, so\ncheck it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra call per scope; fatal if the search surface returns an asset\nthe list sweep did not. `_meta.split_parity` EMPTY means the probe did\nnot run; a probe that ran and found nothing is a record whose\n`only_in_search` is empty. Read the record, not the key — and say which\nin the report.\n\nEvery entry that did not come from CAI is stamped into\n`inventory.json`'s `_meta.native_sweeps` with the verbatim command;\nthose entries, and `_meta.split_type_sweeps`, belong in the step-5\nreport. Collection closes with a one-line cost summary (`N gcloud\ncall(s) in Xs: …`) and records every command it ran in\n`_meta.api_calls`; add `--verbose` after the subcommand to watch each\ncall as it happens, and quote the summary line in the report — on a\nlarge estate the per-container org-policy sweep dominates the run, and\nthis is what makes that visible instead of guessed at.\n\n### Step 2 — get your worklist\n\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\nFirst run: everything is missing. Re-runs: only the delta.\n\n### Step 3 — map and emit (your job)\n\n**Fabric modules are the product, not an optimization.** Every resource\nmaps to its canonical Fabric module (the cookbook's \"Canonical module\nmap\" is normative): `modules/organization` for everything org-level,\nand — by default — one `modules/folder` instance per folder and one\n`modules/project` instance per project, each carrying its own IAM, org\npolicies, and sinks. For families where Fabric also offers a factory\ncarrier (e.g. `project-factory` for the folder hierarchy,\n`net-firewall-policy` rule factories), the manifest may opt into\n`factory` emission per resource family via `emission:` — a human call,\nmade when the imported workspace is meant to become the day-2\noperating model (e.g. foundational folders + org policies + IAM as\nYAML data). The default stays per-instance: shallow, chosen addresses\nwith no coupling to factory internals. The cookbook's \"Factory\nemission (opt-in)\" section is normative for how, and for the\ntradeoffs.\n\nFor each worklist entry, emit module config (factory YAML or module\ninputs, per the cookbook) + a paired import block targeting the\nmodule-internal address, maintaining `tf/coverage-map.yaml`\n(inventory key → list of Terraform addresses). Read\n`references/mapping-cookbook.md` FIRST: it encodes the hard-won rules\n(escaping, import-ID formats, coalesce traps, dry-run keys, hashed\ncondition keys).\n\n**IAM is emitted additive by default.** Bindings map to\n`iam_bindings_additive` (one `google_*_iam_member` per role/member/\ncondition tuple): an apply can only create or destroy the exact pairs\nemitted and can never strip members it does not manage — the posture\nGoogle itself recommends for PAM coexistence. Authoritative emission\n(`iam` / `iam_bindings`) is a deliberate opt-in via `emission.iam:\nauthoritative` in the manifest, for estates that want IAM fully\ndeclarative as the day-2 model; the cookbook's container-IAM rules\ncover both and the tradeoff.\n\n**Machine-managed IAM is excluded, never imported — and never part of\nthe denominator.** `inventory.py` strips Privileged Access Manager\ngrant bindings deterministically before the denominator is formed —\nnothing to map, nothing to waive — and stamps them in\n`_meta.pam_grant_exclusions`; report them in step 5, like `deleted:`\nprincipal tombstones (which remain a mapping-time exclusion). PAM\n*entitlements* are ordinary importable configuration; only *grants*\nare excluded. The cookbook's container-IAM rules are normative,\nincluding the strip mechanics.\n\nWhen the cookbook has no section for a type — which is the normal case,\nnot an error — follow the method below. The cookbook is the precipitate\nof that method, not a precondition for it.\n\n**Raw `google_*` resources are the exception, never a shortcut.**\nPermitted only when (a) the canonical module cannot express a required\nattribute of the live resource (a *module capability gap*), or (b) no\nFabric module covers the type at all. Every raw resource MUST appear in\nthe step-5 report's \"module capability gaps\" section with the concrete\nreason. For drafting raw config,\n`terraform plan -generate-config-out=generated.tf` is provider-faithful\nby construction — but it is an inspection technique, not a mapping\ntier.\n\nRead the Fabric module source **in this repository** before targeting\nmodule-internal addresses; do not trust memory. Root-module wiring\n(`versions.tf`, provider config, module blocks) is part of your output.\n\nThree cookbook sections are normative for HOW you emit: **Workspace\nlayout** (one file per CONTAINER; project-contained resources live in\ntheir project's file; hierarchical `data/` keyed by instance keys),\n**The reference rule** (managed resources are referenced via module\noutputs, never literals), and **Scaffolding scripts** (run-local\ngenerators are fine — parameterized, cookbook-conformant, deterministic,\nnever promoted into the skill).\n\n### Step 3b — when the cookbook has never seen this type\n\nThe cookbook will never be comprehensive, and it is not meant to be. It\nis the accumulated residue of running this loop against real resources;\nevery entry in it was produced this way. Meeting an undocumented type is\nroutine — apply the same method, at the same standard of evidence.\n\nWhat makes that safe is division of labour. **The gates make the method\nsafe**: you cannot take a wrong mapping to green, because a bad import\nID errors, a mismatched attribute stays residual, and an unmapped asset\nfails coverage. **The cookbook makes it cheap and correctly diagnosed**:\nit is the difference between converging on the first attempt and the\nthird, and — more importantly — between identifying a diff's cause and\nconfidently misreading it. The gates cannot catch a misdiagnosis, because\nclassifying a diff as benign is a human judgement they delegate. Do not\ntreat an absent cookbook section as licence to reason loosely.\n\nThe loop:\n\n1. **Census the type.** Confirm the CAI asset-type string against live\n   `gcloud asset list` output — type strings from module knowledge have\n   been wrong before, and a non-zero yield is NOT proof the type is\n   complete (split families answer happily with part of the family).\n   Record which enumeration path had to be used. Where the type has any\n   global/regional/zonal distinction, census BOTH surfaces and\n   reconcile the counts before trusting either; where CAI does not\n   model the type at all, enumerate it another way and merge it into\n   the same denominator (step 1, `references/cai-blind-spots.md`) —\n   never narrow the manifest around it.\n2. **Find the module and read its source.** Canonical module map first,\n   then `modules/` in this repository. Read the resource blocks for\n   addresses and `for_each` key formats, and the variables file for the\n   input shape. Never work from memory about module internals.\n3. **Mirror ForceNew inputs to live values BEFORE the first plan.**\n   Check which inputs are ForceNew on the underlying resource and set\n   them to exactly what is live. A destroy/create pair on an imported\n   resource is always a mapping error, never an acceptable outcome.\n4. **Emit, plan, and read the result.** The plan is the oracle. A wrong\n   import ID fails loudly and safely; a wrong attribute shows up as a\n   diff. This step is cheap — use it rather than deliberating.\n5. **Diagnose every diff before classifying it**, in this order:\n   - Read the module's variable TYPE. Terraform silently discards\n     attributes a variable type does not declare — no error, `validate`\n     returns Success — so a field you believe you set may never have\n     reached the resource.\n   - Check whether the field is assigned directly or wrapped in\n     `coalesce()`. Directly assigned fields accept `null` and need no\n     rule; `coalesce` fields cannot express \"empty\" at all.\n   - Check the escaping matrix if the value passes through\n     `templatestring()`.\n   Only after those does the question \"is this benign?\" become\n   answerable.\n6. **Land on one of three outcomes — all legitimate:** clean\n   convergence; a *module capability gap* (the module cannot express an\n   attribute that changes behaviour — emit a raw resource mirroring\n   live values, name the module and the missing attribute in the\n   report's capability-gaps section; a successful outcome and\n   upstream-Fabric-issue material); or *no module covers the type*\n   (same, plus whether a module is worth proposing upstream). A\n   residual you cannot explain is none of these: report it and stop.\n7. **Write the cookbook entry as part of your report.** A type you\n   mapped and verified is knowledge the next engagement should not have\n   to rediscover: the CAI type string, the module addresses and\n   `for_each` key formats, the import ID, the ForceNew inputs, and every\n   trap that cost you a cycle. Mark surfaces you did not exercise as\n   unverified. This step is what makes the tool compound instead of\n   resetting to zero on every engagement — without it, the same traps\n   are paid for repeatedly.\n\n### Step 4 — run the gates\n\n**The two gates measure different things, and neither can catch the\nother's failure.** Gate 1 compares the denominator against the TEXT of\nyour workspace — the `import {}` blocks it parses out of `*.tf`, the\ncoverage map, the waivers. It never runs Terraform and never reads your\ncloud. Gate 2 compares the plan against reality.\n\nSo gate 1 passes happily on code that is completely wrong, and gate 2\npasses happily on a workspace covering three of fifty-nine assets: a\nplan of three clean imports is perfectly converged, because Terraform\nonly knows about what you wrote down. **A converged plan is not evidence\nof completeness**, and a reconciled coverage report is not evidence of\ncorrectness. Never report one as if it covered the other, and never\nskip a gate because the other one is green.\n\n**An orphan import block is evidence about the DENOMINATOR, not just\nabout the coverage map.** When gate 1 reports an `import {}` block\ntargeting a resource that is not in `inventory.json`, and you know that\nresource is live, the first hypothesis is that enumeration missed it —\nnot that the mapping is spurious. This is the only signal that fires for\na silently short denominator, and it has been mistaken for a coverage-map\nproblem and waived away in a live run. Investigate the enumeration path\nfor that type (step 1, and `references/cai-blind-spots.md`) BEFORE\nproposing a waiver: a waiver over a short denominator makes the gap\npermanent and puts a human's name on it.\n\n```bash\nterraform -chdir=tf fmt -recursive   # generated code is ALWAYS fmt-ed\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # never let a stale plan answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` here: it returns **2 whenever the plan is\nnon-empty**, and a converged import plan is always non-empty (every\n`import {}` block is a planned change). Under `set -e`, or to an agent\ntreating non-zero as failure, it aborts the workflow at exactly the step\nthat is meant to validate it. `verify_plan.py` is the verdict, not\nterraform's exit code.\n\nThe `rm -f` matters: `plan` and `show` are separate commands, so if the\nplan step fails (expired credentials, a provider error, a syntax error in\nnewly emitted HCL) `show -json` will happily read the PREVIOUS\niteration's plan file and the gate can print CONVERGED for code that was\nnever planned.\n\n`verify_plan.py` exit codes: `0` converged, `1` malformed input, `2`\nresidual changes, `3` advisory run (a substituted `--rules` file — never\na passing gate).\n\n`--require-signed-waivers` is the default posture: every waiver must\ncarry a `signed_by` recording the human who accepted it. Drop the flag\nonly in a fully unattended run, and say so in the report.\n\nIterate step 3 until **both** gates are green. Convergence =\ncoverage reconciled ∧ every plan change is a clean import, no-op, or\nreviewed-benign.\n\n### Step 5 — report\n\nProduce a run report for the user: what was imported (counts by type,\nwith the Fabric module used for each), an **enumeration sources**\nsection naming every type that did not come from CAI and the exact\ncommand that produced it (`_meta.native_sweeps` covers declared\nenumerators; quote anything you enumerated out of band yourself), a\n**module capability gaps** section — every raw resource emitted, which\nmodule fell short, which attribute, and whether it is\nupstream-Fabric-issue material — plus\nbenign diffs relied on, waivers in effect, proposed waivers/benign rules\nawaiting human review, and anything the manifest excludes that you\nbelieve deserves attention. State whether `--verify-search-parity` was\nrun and what it found. An empty `_meta.split_parity` means the probe did\nnot run; a clean probe is a record whose `only_in_search` is empty.\nReporting an unchecked table as clean is exactly the kind of claim this\nsection exists to prevent. Quote the **per-scope** yield tables\n(`_meta.scopes[].declared_types` / `zero_yield_types`), not only the\naggregate `_meta.declared_types`: a type can be non-zero org-wide while\nthe one scope that declared it yielded nothing, and only the per-scope\nrecord shows that. Convergence claims are bounded per scope — state\neach scope's declared surface, not one sentence about \"the manifest\".\nPlain facts; no \"100%\" claims beyond what the\ngates literally verified.\n\nThe report MUST include the verbatim stdout of both final gate runs —\nincluding the provenance stamp and every `input … sha256:` line.\nThose lines are what lets a reviewer tie the verdict to the exact tool\nbuild and the exact inputs; a report that paraphrases the gates instead\nof quoting them is not evidence.\n\n## Incremental runs\n\n- The workspace persists. `coverage.py`'s worklist is the only to-do\n  list; touch nothing that is already mapped.\n- Commit `.terraform.lock.hcl` in the (user-owned, private) workspace\n  repo: convergence verdicts are only reproducible against the exact\n  provider build that produced them.\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable. If a mapping must genuinely change (e.g. folder renamed),\n  emit `moved {}` blocks and flag it prominently in the report.\n- Manifest NARROWING fails closed: `coverage.py` flags now-out-of-scope\n  mapped keys as `stale?` problems and exits 2. Resolution is a human\n  decision: re-widen the manifest, sign waivers, or deliberately retire\n  the mappings (remove config + import blocks + coverage-map entries in\n  one reviewed commit) — never silently.\n- Manifest widening only ever adds worklist entries; it never invalidates\n  existing mappings.\n\n## Prerequisites\n\n`terraform` >= 1.5, `gcloud`, and [`uv`](https://docs.astral.sh/uv/).\n\nRun every script with `uv run scripts/<name>.py`. Each one declares its\nown dependencies inline (PEP 723), so nothing needs installing or\nactivating and the run cannot pick up a stale system PyYAML. Where `uv`\nis genuinely unavailable, `python3` >= 3.10 with PyYAML installed works\nand the arguments are identical — say which you used in the report.\n\nDiscovery IAM — grant the read-only role set from the README's \"Minimal\nRead-Only IAM Permissions\" table on the scope root. It covers org\nfoundation, folders, projects, networking, and storage; families\nverified later (KMS, CAS, VPC-SC, BigQuery, Pub/Sub, Tags, WIF) may\nneed additional per-service viewer roles — the plan gate names the\nmissing permission when one is needed.\n\n## References\n\n- [README.md](./README.md) — human user guide and architecture overview\n- [COVERAGE.md](./COVERAGE.md) — resource maturity matrix\n- [references/mapping-cookbook.md](./references/mapping-cookbook.md) —\n  Fabric mapping rules, escaping, import-ID table, known traps\n- [references/address-map.yaml](./references/address-map.yaml) — the\n  fielded subset of the cookbook (asset type, module, address, import\n  ID, verification round); validated and rendered by\n  `scripts/address_map.py`, never read by the gates\n- [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md)\n  — inferring import manifests from existing Terraform states\n- [references/manifest-migration.md](./references/manifest-migration.md)\n  — the manifest grammar changed to scopes-only with per-scope\n  `types:` lists; how to migrate a manifest written for the retired\n  grammar, and what stayed identical\n- [references/cai-blind-spots.md](./references/cai-blind-spots.md) —\n  where the CAI denominator is incomplete and what to do about it\n- [references/operating-contract.md](./references/operating-contract.md)\n  — invariants and trust boundaries in full\n","skills/fabric-importer/examples/import-manifest.fast-networking.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-networking: the\n# networking projects and the connectivity stack.\n#\n# Maturity: core VPC surface, DNS zones, Routers/NAT, and linked-VPC NCC\n# spokes are [V]; advanced policies and VPN are [C]/[N]. Scope to your\n# networking folder. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/333333333333         # networking folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: compute.googleapis.com/Network                   # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork                # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Route                     # [V] (dynamic routes waived)\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone                   # [V] modules/dns\n        levels: [project]\n      - type: dns.googleapis.com/Policy                        # [N] DNS server policies\n        levels: [project]\n      - type: dns.googleapis.com/ResponsePolicy                # [N]\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Hub           # [N] NCC hub\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Spoke         # [V] NCC linked-VPC spoke (raw fallback)\n        levels: [project]\n      - type: compute.googleapis.com/Router                    # [V] modules/net-cloudnat (router + NAT)\n        levels: [project]\n      - type: compute.googleapis.com/Address                   # [N] reserved addresses\n        levels: [project]\n      # - type: compute.googleapis.com/FirewallPolicy          # [C] net-firewall-policy\n      #   levels: [folder]\n      # - type: compute.googleapis.com/VpnGateway              # [C] net-vpn-*\n      #   levels: [project]\n      # - type: compute.googleapis.com/VpnTunnel               # [C]\n      #   levels: [project]\n      # - type: compute.googleapis.com/InterconnectAttachment  # [C] vlan attachments\n      #   levels: [project]\n","skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring the surface managed by FAST stage\n# 0-org-setup: org-level governance plus the IaC / billing-export /\n# observability projects, their service accounts, state buckets, tags, and WIF.\n#\n# Maturity legend (see COVERAGE.md): [V] live-verified  [C] cookbook\n# rules exist, not yet plan-tested  [N] new territory (fallback +\n# capability-gap report). Validate counts with `inventory.py collect`\n# before mapping; adjust the scope's include to your IaC folder/projects.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000   # <- your org id\n    # include/exclude match CAI `ancestors` (NUMERIC project ids).\n    # Restrict project-level types to the org-setup projects:\n    # include:\n    #   - projects/111111111111        # iac-core project NUMBER; see\n    #     import-manifest.multi-domain.yaml for selecting specific\n    #     projects per scope, and import-manifest.org-foundation.yaml\n    #     for the include-vs-exclude numeric-id trap\n    types:\n      - type: iam                                              # [V]\n        levels: [organization, project]\n      - type: org-policy                                       # [V]\n        levels: [organization]\n      - type: iam.googleapis.com/Role                          # [V] (org custom roles)\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink                   # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder       # [V]\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [organization, folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true          # impersonation grants enter the denominator\n      - type: logging.googleapis.com/LogBucket                 # [V]\n        levels: [project]\n      - type: storage.googleapis.com/Bucket                    # [V] state buckets\n        levels: [project]\n      - type: iam.googleapis.com/WorkloadIdentityPool          # [V] WIF federation\n        levels: [project]\n      - type: cloudresourcemanager.googleapis.com/TagKey       # [V] Resource Manager tags\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagValue     # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagBinding   # [V]\n        levels: [organization, folder, project]\n      # --- Optional additional types ---\n      # - type: cloudbilling.googleapis.com/BillingAccount     # [N] billing IAM\n      #   levels: [organization]\n","skills/fabric-importer/examples/import-manifest.fast-security.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-security: the security\n# projects plus KMS keyrings/keys and Certificate Authority Service (CAS).\n#\n# Maturity (see COVERAGE.md): [V] live-verified.\n# Note: KMS keyrings cannot be deleted in GCP; CAS pools bill monthly.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/222222222222         # security folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing                  # [V] modules/kms\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey                # [V] modules/kms\n        levels: [project]\n        iam: true          # per-key IAM is leaf IAM - opt in explicitly\n      - type: privateca.googleapis.com/CaPool                  # [V] modules/certificate-authority-service\n        levels: [project]\n      # - type: privateca.googleapis.com/CertificateAuthority  # [C]\n      #   levels: [project]\n","skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 1-vpcsc: the org access\n# policy, access levels, and service perimeters (modules/vpc-sc).\n#\n# Maturity: [V] live-verified (dry-run spec verified live; enforced\n# perimeter status block unverified). Access policies are org-level\n# singletons. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    types:\n      - type: identity.accesscontextmanager.googleapis.com/AccessPolicy      # [V]\n        levels: [organization]\n      - type: identity.accesscontextmanager.googleapis.com/AccessLevel       # [V]\n        levels: [organization, unknown]\n      - type: identity.accesscontextmanager.googleapis.com/ServicePerimeter  # [V]\n        levels: [organization, unknown]\n","skills/fabric-importer/examples/import-manifest.multi-domain.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"multi-domain\" — PER-SCOPE TYPES\n#\n# One estate, four different depths. Each scope carries its own `types:`\n# list — there is no other place to declare types, so what a scope\n# collects is exactly what is written on it. The same type may appear in\n# several scopes with different `levels`, `iam` or `enumerate`; that is\n# the point, not an accident.\n#\n# Fail-closed rules (all exit 1 with the reason, before any API call):\n#   - a scope without a `types:` list is refused;\n#   - `types: []` is refused: an empty list is a scope that collects\n#     nothing, and that is the one thing no scope may say quietly;\n#   - a type whose `levels` cannot intersect its scope's `levels` is\n#     refused as a dead declaration (an entry listing `unknown` is\n#     exempt — see import-manifest.fast-vpcsc.yaml);\n#   - a duplicate `type:` inside one scope's list is refused.\n#\n# Note the asymmetry with `emission:` (also per scope): emission INHERITS\n# built-in defaults for families you omit, because it only decides how\n# collected assets are written. `types:` never inherits anything, because\n# it decides what is collected at all.\n#\n# After collection, read the PER-SCOPE yield tables in inventory.json\n# (`_meta.scopes[].declared_types` / `zero_yield_types`): the aggregate\n# table can be non-zero for a type while one scope's own declaration\n# yielded nothing — e.g. `iam` non-empty org-wide but empty under\n# `workloads`. Every inventory entry also names the scope(s) that\n# collected it (`scopes: [...]`).\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Org foundation: governance only, no project-contained resources.\n  - name: org-foundation\n    root: organizations/000000000000        # <- your org id\n    levels: [organization, folder]\n    emission:\n      folder: factory                       # day-2 operating model\n    types:\n      - type: iam\n        levels: [organization, folder]\n      - type: org-policy\n        levels: [organization, folder]\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  # Networking folder: the connectivity stack, plus folder-level\n  # governance for that subtree only. Same `iam` and `org-policy` types\n  # as above, different levels — per-scope lists make that expressible.\n  - name: networking\n    root: folders/111111111111              # <- networking folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: compute.googleapis.com/Network\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork\n        levels: [project]\n      - type: compute.googleapis.com/Router\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone\n        levels: [project]\n\n  # Security folder: KMS and service accounts, with leaf IAM opted in\n  # per type. `iam: true` is a property of the type ENTRY, so it is\n  # per-scope too: another scope may collect the same CryptoKey type\n  # without leaf IAM.\n  - name: security\n    root: folders/222222222222              # <- security folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey\n        levels: [project]\n        iam: true\n\n  # Two workload projects, named explicitly. Project IAM is in scope\n  # here and nowhere else; no org-policy at all — a deliberate, declared\n  # gap, not a silent one.\n  - name: workloads\n    root: organizations/000000000000\n    levels: [project]\n    include:\n      - projects/333333333333               # app-prod  (project NUMBER)\n      - projects/444444444444               # app-dev\n    types:\n      - type: iam\n        levels: [project]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n\n# ---------------------------------------------------------------------------\n# `enumerate:` blocks live in the list that declares them: a natively\n# enumerated type is swept only in the scopes whose `types:` names it.\n# Repeat the block in every scope that needs it — see\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","skills/fabric-importer/examples/import-manifest.org-foundation.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"organization foundation\"\n#\n# This manifest reproduces exactly the surface the legacy (v1) pipeline\n# covered: org-level custom roles, org IAM (bindings + audit configs), org\n# policies, org log sinks, the folder hierarchy, and folder IAM.\n#\n# A manifest is the user-owned contract for a fabric-importer run:\n#   - inventory.py collects ONLY what the scopes declare (the completeness\n#     denominator becomes \"what you asked for\", not \"everything\");\n#   - coverage.py fails the run until every collected asset is mapped to a\n#     Terraform address or waived by a human;\n#   - re-runs are incremental: widen a scope's `types:` list (or let the\n#     org grow) and only the delta is added to the workspace. Existing\n#     addresses are never rewritten.\n#\n# The grammar is scopes-only: `scopes:` is a list, and EVERY scope carries\n# its own `types:` list. One estate, different depths per subtree = several\n# scopes, each with its own list — see import-manifest.multi-domain.yaml\n# for the worked multi-scope example and the fail-closed rules.\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Root of the import. organizations/N, folders/N and projects/N are all\n  # valid roots; everything below is evaluated inside this subtree.\n  - root: organizations/000000000000   # <- replace with your org id\n\n    # Optional: restrict to specific subtrees inside the root. An asset is\n    # in scope only if one of these appears among its ancestors. Omit to\n    # cover the whole root.\n    # include:\n    #   - folders/111111111111\n\n    # Optional: carve out subtrees. Exclusion wins over inclusion.\n    #\n    # NOTE (live-verified): include/exclude match against the CAI\n    # `ancestors` array, which stores NUMERIC ids. Both spellings work:\n    # `projects/123456789` (the project NUMBER) matches directly, and a\n    # project ID is resolved to its number by inventory.py via\n    # `gcloud projects describe`. Prefer the number where you know it —\n    # resolution needs the describe permission, and a project that cannot\n    # be resolved is reported as an enumeration failure rather than\n    # silently matching nothing.\n    # exclude:\n    #   - projects/123456789012\n\n    # Optional: emission style, per resource family. YOUR call — the\n    # scripts do not consume it; it directs step-3 mapping (the\n    # cookbook's \"Factory emission (opt-in)\" section is normative).\n    #   per-instance (default) : one module instance per container.\n    #   factory                : data-driven YAML via the family's\n    #                            Fabric factory carrier.\n    # An org foundation is the canonical factory case: folder tree,\n    # folder IAM and org policies maintained as hierarchical data/ YAML\n    # via project-factory.\n    #\n    # `emission` is per scope and INHERITS: a family key you omit gets\n    # the built-in default (per-instance; additive for iam). `types:`\n    # does NOT: every scope declares its full list, and an omission is\n    # an error, not inheritance. Types decide the denominator; emission\n    # only decides how collected assets are written. Do not reason from\n    # one knob to the other.\n    #\n    # emission:\n    #   folder: factory\n    #   iam: additive     # default — google_*_iam_member per tuple; an\n    #                     # apply can never strip unmanaged members. Use\n    #                     # `authoritative` to make IAM fully declarative\n\n    types:\n      # ---------------------------------------------------------------\n      # IAM policies (pseudo-type). CAI models IAM as a content type\n      # attached to resources, not as an asset type, hence the reserved\n      # name `iam`. `levels` controls WHERE bindings are replicated:\n      #   organization -> org IAM bindings + audit configs\n      #                   (including logging_data_access)\n      #   folder       -> folder IAM bindings\n      # Project-level IAM stays unmanaged in this example (add `project`\n      # to take it over). Only container-attached IAM is covered by this\n      # pseudo-type; IAM on leaf assets (tag values, buckets, service\n      # accounts, ...) needs an `iam: true` opt-in on the leaf type's\n      # own entry.\n      # ---------------------------------------------------------------\n      - type: iam\n        levels: [organization, folder]\n\n      # ---------------------------------------------------------------\n      # Org policies (pseudo-type; CAI content type org-policy). One\n      # inventory entry per (resource, constraint) pair, so coverage is\n      # per-constraint. This example covers org-level only; declaring\n      # [organization] makes the folder-level restriction explicit and\n      # honest: folder policies are out of scope BY DECLARATION rather\n      # than silently dropped.\n      # ---------------------------------------------------------------\n      - type: org-policy\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Custom IAM roles. Fabric target: modules/organization factory,\n      # data/custom-roles/<role_id>.yaml. Org-level only; project-level\n      # custom roles would need `project` here.\n      # ---------------------------------------------------------------\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Log sinks. Fabric target: modules/organization logging_sinks\n      # locals (with iam=false; _Default/_Required are quarantined).\n      # Folder and project sinks stay unmanaged here.\n      # ---------------------------------------------------------------\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Folder hierarchy. Fabric target: one modules/folder instance per\n      # folder (stable instance keys; parent chaining by module\n      # reference). `levels` refers to the CONTAINER of the asset: a\n      # folder whose parent is the org sits at level `organization`, a\n      # nested folder at `folder`.\n      # ---------------------------------------------------------------\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n# ---------------------------------------------------------------------------\n# What this manifest deliberately does NOT cover (would have been silent\n# gaps in v1; here they are visible declarations — add entries to the\n# scope's `types:` list to take them over):\n#\n#   - type: cloudresourcemanager.googleapis.com/Project\n#     levels: [organization, folder]\n#   - type: orgpolicy.googleapis.com/CustomConstraint\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagKey\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagValue\n#     levels: [organization]\n#   - type: essentialcontacts.googleapis.com/Contact\n#     levels: [organization]\n#   - type: compute.googleapis.com/Network        # modules/net-vpc\n#   - type: iam.googleapis.com/ServiceAccount     # modules/iam-service-account\n#     levels: [project]\n#     iam: true    # ALSO collect IAM attached to each SA (impersonation\n#                  # grants). Leaf-asset IAM never enters the denominator\n#                  # without this per-type opt-in.\n# ---------------------------------------------------------------------------\n\n# ---------------------------------------------------------------------------\n# TYPES CLOUD ASSET INVENTORY DOES NOT MODEL\n#\n# A type CAI cannot see would be invisible to BOTH gates at once, so it\n# is enumerated by other means and merged into the same inventory —\n# `gcloud` first. Declare it with an `enumerate:` block in the `types:`\n# list of every scope that needs it (per-scope lists never inherit):\n#\n#   - type: iam.googleapis.com/DenyPolicy      # not in the CAI catalogue\n#     levels: [organization, folder]\n#     enumerate:\n#       command: [iam, policies, list, --kind=denypolicies]\n#       container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n#       key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n#\n# Guard rails (read-only verbs only, no --filter/--limit, unique key\n# templates), the built-in enumerator table, and the full ladder (CAI ->\n# gcloud -> REST API -> signed waiver) are in\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","skills/fabric-importer/examples/waivers.example.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED waiver ledger (example).\n#\n# A waiver excludes one in-scope inventory key from the coverage gate:\n# \"we saw it, we decided not to manage it, here is why, signed\". The model\n# operating the skill MUST NOT add or edit waivers; it may only propose\n# them in its report for a human to review and commit.\n#\n# Every waiver requires key + reason. `signed_by` records who accepted\n# it — coverage.py reports waivers without it, and hard-fails them under\n# --require-signed-waivers (recommended whenever a human is in the\n# loop). Include the date in the signed_by value for auditing.\n\nwaivers:\n  - key: //cloudresourcemanager.googleapis.com/folders/000000000001\n    reason: >-\n      Decommissioning next quarter; not worth importing. Tracked in\n      TICKET-123.\n    signed_by: alice@example.com 2026-08-21\n\n  - key: //logging.googleapis.com/organizations/000000000000/sinks/tmp-debug\n    reason: temporary debugging sink owned by the SRE oncall; do not manage\n    signed_by: bob@example.com 2026-08-21\n","skills/fabric-importer/references/address-map.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Address and import-ID map — the FIELDED subset of the mapping cookbook.\n#\n# WHY THIS FILE EXISTS\n#\n# `mapping-cookbook.md` carries two different kinds of knowledge:\n#\n#   1. Fielded tuples — (asset type, module, address pattern, import ID,\n#      verification round). Uniform across every entry, mechanically\n#      checkable, and the part that kept being duplicated: the same\n#      address appeared once in the per-type prose and again in a\n#      hand-maintained markdown table, and three families were added\n#      twice because nothing could detect it.\n#\n#   2. Reasoning — ForceNew traps, provider quirks, waiver postures,\n#      capability gaps. Causal prose that becomes strictly worse when\n#      squeezed into a `notes:` string.\n#\n# Only (1) lives here. (2) stays in the cookbook, in prose, on purpose.\n# This file is NOT a specification of what the skill supports and NOT a\n# closed world: a type absent from it is a type nobody has run the\n# method on yet. See \"Step 3b\" in SKILL.md. Never treat a miss here as\n# a reason to stop, and never treat a hit here as a substitute for\n# reading the module source.\n#\n# NOT FROZEN. This file is advisory input to `address_map.py`, which is\n# a linter, not a gate. It is deliberately outside the trust boundary in\n# `integrity.py`: a wrong entry here makes `terraform plan` error\n# loudly, unlike a wrong `benign-drift.yaml` entry which silently passes\n# a gate. Different blast radius, different rigour budget.\n#\n# SCHEMA (enforced by `scripts/address_map.py --validate`)\n#\n#   version: 1\n#   entries:\n#     <slug>:                # stable, kebab-case, immutable once used\n#       resource:      str   # human label, used for the rendered table\n#       module:        str   # 'modules/<name>', or 'raw' for a gap\n#       address:       str   # Terraform address pattern, <placeholder>s\n#       import_id:     str   # provider import ID pattern\n#       asset_type:    str   # CAI asset type, or null when not CAI-modelled\n#       verified:      str   # 'r<N>' round that proved it, or false\n#       note:          str   # optional, ONE line; prose belongs in the cookbook\n#       disambiguation: str  # REQUIRED when an asset_type maps to >1 module\n#\n# INVARIANTS (all machine-enforced)\n#   - `address` is globally unique: no two entries may claim it.\n#   - when one `asset_type` maps to more than one `module`, EVERY entry\n#     sharing that type must carry a non-empty `disambiguation`.\n#   - `verified` is false or matches `r<digits>`.\n#   - `note` is a single line (multi-line reasoning goes in the cookbook).\n#\n# Regenerate the cookbook's rendered table after editing:\n#   uv run scripts/address_map.py --render-cookbook\n\nversion: 1\n\nentries:\n\n  # --- organization -------------------------------------------------\n\n  org-custom-role:\n    resource: Custom role\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_custom_role.roles[\"<id>\"]'\n    import_id: 'organizations/<org>/roles/<id>'\n    asset_type: iam.googleapis.com/Role\n    verified: r3\n\n  org-iam-member:\n    resource: Org IAM member (additive, default)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_member.bindings[\"<key>\"]'\n    import_id: '<org> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  org-iam-binding-authoritative:\n    resource: Org IAM binding (authoritative opt-in)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<org> <role>'\n    asset_type: null\n    verified: r3\n\n  org-iam-binding-conditional:\n    resource: Org IAM binding (cond.)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.bindings[\"<key>\"]'\n    import_id: '<org> <role> <condition_title>'\n    asset_type: null\n    verified: r3\n\n  org-audit-config:\n    resource: Org audit config\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_audit_config.default[\"<service>\"]'\n    import_id: '<org> <service>'\n    asset_type: null\n    verified: r3\n\n  org-policy:\n    resource: Org policy\n    module: modules/organization\n    address: 'module.organization.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'organizations/<org>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r5\n    disambiguation: 'org-level constraints; folder and project scopes have their own entries'\n\n  org-log-sink:\n    resource: Org log sink\n    module: modules/organization\n    address: 'module.organization.google_logging_organization_sink.sink[\"<name>\"]'\n    import_id: 'organizations/<org>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r3\n    disambiguation: 'org-level sinks; folder sinks are carried by modules/folder'\n\n  org-tag-key:\n    resource: Tag Key\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_key.default[\"<short_name>\"]'\n    import_id: 'tagKeys/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagKey\n    verified: r18\n\n  org-tag-value:\n    resource: Tag Value\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]'\n    import_id: 'tagValues/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagValue\n    verified: r18\n\n  tag-binding:\n    resource: Tag Binding\n    module: modules/organization\n    address: 'module.<instance>.google_tags_tag_binding.binding[\"<key>\"]'\n    import_id: 'tagBindings/<url-encoded-parent-and-value>'\n    asset_type: cloudresourcemanager.googleapis.com/TagBinding\n    verified: r18\n    note: 'emitted on the container module that owns the bound resource'\n\n  # --- folders ------------------------------------------------------\n\n  folder:\n    resource: Folder\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder.folder[0]'\n    import_id: 'folders/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/Folder\n    verified: r6\n\n  folder-iam-member:\n    resource: Folder IAM member (additive, default)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  folder-iam-binding-authoritative:\n    resource: Folder IAM\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'folders/<id> <role>'\n    asset_type: null\n    verified: r6\n\n  folder-iam-binding-conditional:\n    resource: Folder IAM (cond.)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <condition_title>'\n    asset_type: null\n    verified: r6\n\n  folder-org-policy:\n    resource: Folder org policy\n    module: modules/folder\n    address: 'module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'folders/<id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r6\n    disambiguation: 'folder-scoped constraints; org and project scopes have their own entries'\n\n  folder-log-sink:\n    resource: Folder sink\n    module: modules/folder\n    address: 'module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]'\n    import_id: 'folders/<id>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r6\n    disambiguation: 'folder-level sinks; org sinks are carried by modules/organization'\n\n  # --- projects -----------------------------------------------------\n\n  project:\n    resource: Project\n    module: modules/project\n    address: 'module.project-<key>.google_project.project[0]'\n    import_id: 'projects/<project_id>'\n    asset_type: cloudresourcemanager.googleapis.com/Project\n    verified: r4\n\n  project-service:\n    resource: Project service\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.project_services[\"<api>\"]'\n    import_id: '<project_id>/<api>'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-service-orgpolicy:\n    resource: Project service (orgpolicy)\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.org_policy_service[0]'\n    import_id: '<project_id>/orgpolicy.googleapis.com'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-iam-member:\n    resource: Project IAM member (additive, default)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_member.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  project-iam-binding-authoritative:\n    resource: Project IAM\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<project_id> <role>'\n    asset_type: null\n    verified: r4\n\n  project-iam-binding-conditional:\n    resource: Project IAM (cond.)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <condition_title>'\n    asset_type: null\n    verified: r4\n\n  project-org-policy:\n    resource: Project org policy\n    module: modules/project\n    address: 'module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'projects/<project_id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: false\n    disambiguation: 'project-scoped constraints; org and folder scopes have their own entries'\n\n  workload-identity-pool:\n    resource: Workload Identity Pool\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPool\n    verified: r18\n\n  workload-identity-pool-provider:\n    resource: Workload Identity Pool Provider\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPoolProvider\n    verified: r18\n\n  pam-entitlement:\n    resource: PAM entitlement\n    module: modules/project\n    address: 'module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]'\n    import_id: '<parent>/locations/<location>/entitlements/<entitlement_id>'\n    asset_type: privilegedaccessmanager.googleapis.com/Entitlement\n    verified: false\n    note: '<parent> is organizations/<org>, folders/<id> or projects/<project_id>; grants are excluded, never imported'\n\n  # --- service accounts ---------------------------------------------\n\n  service-account:\n    resource: Service account\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account.service_account[0]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email>'\n    asset_type: iam.googleapis.com/ServiceAccount\n    verified: r8\n\n  service-account-iam-member:\n    resource: SA IAM member (additive, default)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  service-account-iam-binding-authoritative:\n    resource: SA IAM binding\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role>'\n    asset_type: null\n    verified: r8\n\n  service-account-iam-binding-conditional:\n    resource: SA IAM binding (cond.)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <condition_title>'\n    asset_type: null\n    verified: r8\n\n  # --- logging and storage ------------------------------------------\n\n  log-bucket:\n    resource: Log bucket\n    module: modules/logging-bucket\n    address: 'module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]'\n    import_id: 'projects/<project_id>/locations/<location>/buckets/<name>'\n    asset_type: logging.googleapis.com/LogBucket\n    verified: r10\n\n  gcs-bucket:\n    resource: GCS bucket\n    module: modules/gcs\n    address: 'module.<instance>.google_storage_bucket.bucket[0]'\n    import_id: '<project_id>/<bucket_name>'\n    asset_type: storage.googleapis.com/Bucket\n    verified: r12\n    note: 'never import bare <bucket_name> — the project qualifier is a ForceNew trap'\n\n  bigquery-dataset:\n    resource: BigQuery Dataset\n    module: modules/bigquery-dataset\n    address: 'module.<instance>.google_bigquery_dataset.default'\n    import_id: 'projects/<project_id>/datasets/<dataset_id>'\n    asset_type: bigquery.googleapis.com/Dataset\n    verified: r17\n\n  pubsub-topic:\n    resource: Pub/Sub Topic\n    module: modules/pubsub\n    address: 'module.<instance>.google_pubsub_topic.default'\n    import_id: 'projects/<project_id>/topics/<topic_name>'\n    asset_type: pubsub.googleapis.com/Topic\n    verified: r17\n\n  # --- VPC networking -----------------------------------------------\n\n  vpc-network:\n    resource: VPC network\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_network.network[0]'\n    import_id: 'projects/<project_id>/global/networks/<name>'\n    asset_type: compute.googleapis.com/Network\n    verified: r3\n\n  vpc-subnet:\n    resource: VPC subnet\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r3\n    disambiguation: 'ordinary subnets; proxy-only and private-NAT subnets have their own entries'\n\n  vpc-subnet-proxy-only:\n    resource: VPC subnet (proxy-only)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r9\n    disambiguation: 'purpose REGIONAL_MANAGED_PROXY or GLOBAL_MANAGED_PROXY on the live subnet'\n\n  vpc-subnet-private-nat:\n    resource: VPC subnet (private NAT)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: false\n    disambiguation: 'purpose PRIVATE_NAT on the live subnet'\n\n  vpc-route-gateway:\n    resource: VPC route (gateway)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_gateway on the live route'\n\n  vpc-route-ilb:\n    resource: VPC route (ilb)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ilb on the live route'\n\n  vpc-route-instance:\n    resource: VPC route (instance)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_instance on the live route'\n\n  vpc-route-ip:\n    resource: VPC route (ip)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ip on the live route'\n\n  vpc-route-vpn-tunnel:\n    resource: VPC route (vpn tunnel)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_vpn_tunnel on the live route'\n\n  # --- VPC firewall rules (legacy) ----------------------------------\n\n  vpc-firewall-custom-rule:\n    resource: VPC firewall rule (custom)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/global/firewalls/<rule_name>'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'every rule not generated by the module default-rule toggles'\n\n  vpc-firewall-default-admins:\n    resource: VPC firewall rule (default, admins)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_admins[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-admins'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.admin_ranges, not by custom_rules'\n\n  vpc-firewall-default-http:\n    resource: VPC firewall rule (default, http)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_http[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.http_ranges, not by custom_rules'\n\n  vpc-firewall-default-https:\n    resource: VPC firewall rule (default, https)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_https[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.https_ranges, not by custom_rules'\n\n  vpc-firewall-default-ssh:\n    resource: VPC firewall rule (default, ssh)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_ssh[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.ssh_ranges, not by custom_rules'\n\n  # --- firewall policies --------------------------------------------\n\n  firewall-policy-hierarchical:\n    resource: Hierarchical Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_firewall_policy.hierarchical[0]'\n    import_id: 'locations/global/firewallPolicies/<id>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'org- or folder-attached policy'\n\n  firewall-policy-network-global:\n    resource: Global Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_network_firewall_policy.net_global[0]'\n    import_id: 'projects/<project_id>/global/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, global'\n\n  firewall-policy-network-regional:\n    resource: Regional Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]'\n    import_id: 'projects/<project_id>/regions/<region>/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, regional'\n\n  # --- compute addresses --------------------------------------------\n\n  address-global-psc:\n    resource: Compute address (global PSC)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psc[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'addressType INTERNAL with purpose PRIVATE_SERVICE_CONNECT'\n\n  address-global-psa:\n    resource: Compute address (global PSA)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psa[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose VPC_PEERING'\n\n  address-regional-internal:\n    resource: Compute address (regional internal)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_address.internal[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose GCE_ENDPOINT or SHARED_LOADBALANCER_VIP'\n\n  # --- routers, NAT, VPN, interconnect ------------------------------\n\n  cloud-router:\n    resource: Cloud Router\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'router whose only attached function is Cloud NAT; a router that also terminates VPN tunnels belongs to modules/net-vpn-ha'\n\n  cloud-nat:\n    resource: Cloud NAT\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router_nat.nat'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router>/<nat>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-ha-gateway:\n    resource: HA VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnGateways/<name>'\n    asset_type: compute.googleapis.com/VpnGateway\n    verified: false\n\n  vpn-external-gateway:\n    resource: External VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]'\n    import_id: 'projects/<project_id>/global/externalVpnGateways/<name>'\n    asset_type: compute.googleapis.com/ExternalVpnGateway\n    verified: false\n\n  vpn-tunnel:\n    resource: VPN Tunnel\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnTunnels/<name>'\n    asset_type: compute.googleapis.com/VpnTunnel\n    verified: false\n    note: 'shared_secret is ForceNew and unreadable — see the cookbook section before emitting'\n\n  vpn-router:\n    resource: Cloud Router (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'router that terminates VPN tunnels; a NAT-only router belongs to modules/net-cloudnat'\n\n  vpn-router-interface:\n    resource: Cloud Router interface (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]'\n    import_id: '<project_id>/<region>/<router_name>/<interface_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-router-peer:\n    resource: Cloud Router BGP peer (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  interconnect-attachment:\n    resource: Interconnect Attachment\n    module: modules/net-vlan-attachment\n    address: 'module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/interconnectAttachments/<name>'\n    asset_type: compute.googleapis.com/InterconnectAttachment\n    verified: false\n\n  # --- Cloud DNS ----------------------------------------------------\n\n  dns-managed-zone:\n    resource: DNS zone\n    module: modules/dns\n    address: 'module.<instance>.google_dns_managed_zone.dns_managed_zone[0]'\n    import_id: 'projects/<project_id>/managedZones/<zone>'\n    asset_type: dns.googleapis.com/ManagedZone\n    verified: r12\n\n  dns-record-set:\n    resource: DNS recordset\n    module: modules/dns\n    address: 'module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]'\n    import_id: 'projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>'\n    asset_type: dns.googleapis.com/ResourceRecordSet\n    verified: false\n    note: 'for_each key is \"<type> <name>\" — type first, single space, trailing dot on <name>'\n\n  dns-response-policy:\n    resource: DNS Response Policy\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy.default[0]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>'\n    asset_type: dns.googleapis.com/ResponsePolicy\n    verified: false\n\n  dns-response-policy-rule:\n    resource: DNS Response Policy rule\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>'\n    asset_type: dns.googleapis.com/ResponsePolicyRule\n    verified: false\n\n  # --- KMS ----------------------------------------------------------\n\n  kms-keyring:\n    resource: KMS KeyRing\n    module: modules/kms\n    address: 'module.<instance>.google_kms_key_ring.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>'\n    asset_type: cloudkms.googleapis.com/KeyRing\n    verified: r13\n\n  kms-crypto-key:\n    resource: KMS CryptoKey\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>'\n    asset_type: cloudkms.googleapis.com/CryptoKey\n    verified: r13\n\n  kms-crypto-key-iam:\n    resource: KMS CryptoKey IAM\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>'\n    asset_type: null\n    verified: r13\n\n  # --- Certificate Authority Service --------------------------------\n\n  cas-ca-pool:\n    resource: CAS CA Pool\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name>'\n    asset_type: privateca.googleapis.com/CaPool\n    verified: r14\n\n  cas-ca-pool-iam:\n    resource: CAS CA Pool IAM\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role>'\n    asset_type: null\n    verified: false\n\n  cas-ca-pool-iam-conditional:\n    resource: CAS CA Pool IAM (cond.)\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>'\n    asset_type: null\n    verified: false\n\n  cas-certificate-authority:\n    resource: Certificate Authority\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>'\n    asset_type: privateca.googleapis.com/CertificateAuthority\n    verified: false\n\n  # --- VPC Service Controls -----------------------------------------\n\n  vpcsc-access-policy:\n    resource: VPC-SC Access Policy\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_policy.default[0]'\n    import_id: '<policy_id>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessPolicy\n    verified: r15\n    note: 'import ID is the bare numeric policy id, with no accessPolicies/ prefix'\n\n  vpcsc-access-level:\n    resource: VPC-SC Access Level\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/accessLevels/<level_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessLevel\n    verified: r15\n\n  vpcsc-service-perimeter:\n    resource: VPC-SC Service Perimeter\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/ServicePerimeter\n    verified: r15\n\n  # --- Network Connectivity Center ----------------------------------\n\n  ncc-hub:\n    resource: NCC Hub\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_hub.hub[0]'\n    import_id: 'projects/<project_id>/locations/global/hubs/<hub_name>'\n    asset_type: networkconnectivity.googleapis.com/Hub\n    verified: false\n\n  ncc-spoke-ra:\n    resource: NCC spoke (router appliance)\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_spoke.spoke_ra'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: false\n    disambiguation: 'linked_router_appliance_instances spokes only'\n\n  ncc-spoke-raw:\n    resource: NCC spoke (raw — capability gap)\n    module: raw\n    address: 'google_network_connectivity_spoke.<instance>'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: r12\n    disambiguation: 'linked-VPC and linked-VPN spokes, which no Fabric module carries'\n","skills/fabric-importer/references/cai-blind-spots.md":"# CAI blind spots — where the denominator is incomplete\n\nThe completeness gate is only as strong as Cloud Asset Inventory's\ncoverage. An asset type CAI does not model is invisible to BOTH gates\nsimultaneously (not enumerated → not required by coverage; not emitted →\nnot diffed by plan). Treat this list as living documentation: verify and\nextend it per engagement.\n\nThere are three distinct blind spots, and they need different remedies:\n\n1. **CAI does not model the type at all.** The classic case, and what\n   most of this document is about. Remedy: enumerate it by other means\n   and merge it into the same denominator.\n2. **CAI models the type under a different NAME on the surface you\n   queried.** Rarer, much quieter, and not a blind spot in CAI at all —\n   a blind spot in the question. See\n   [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies).\n3. **CAI models the type but omits a FIELD a consumer depends on.**\n   Quietest of all: the sweep succeeds and the payload simply lacks the\n   attribute. See [Field-level blind spots](#field-level-blind-spots).\n\n## The rule\n\n**CAI is the DEFAULT source of the denominator, never the boundary of\nit.** Anything CAI does not support is retrieved by other means and\nmerged into the same denominator, so the completeness gate still has to\naccount for it. \"CAI cannot see it\" is not a reason for an asset to be\nabsent; it is a reason to enumerate it differently.\n\nThe ladder, in order of preference:\n\n1. **CAI** (`gcloud asset list`, `asset search-all-resources`) — default,\n   because one sweep covers a whole hierarchy and returns ancestry.\n2. **`gcloud <service> list|describe`** — the preferred alternative.\n   Read-only, already installed, already authenticated as the run's\n   read-only identity, and machine-readable with `--format=json`.\n   Declare it in the manifest as a native enumerator (below) so the\n   entries land in `inventory.json` like any others.\n3. **The service REST API** (`curl` with `gcloud auth print-access-token`)\n   — only where gcloud has no surface at all. Enumerate out of band,\n   record the exact call in the run report, and treat any assets found\n   as in scope for mapping.\n4. **A signed waiver plus a run-report entry** — the last resort, and a\n   deliberate, attributed decision. \"Not enumerable\" is a fact to\n   publish, never a silence.\n\n`inventory.py` walks steps 1→2 by itself. It ships a table of built-in\nenumerators (`NATIVE_ENUMERATORS`) for types known to be absent from the\nCAI catalogue: declaring such a type in the manifest is enough, and the\ntool announces that it took the gcloud route. A declared type that CAI\nrejects and no enumerator covers fails the run with the remedy, instead\nof a generic enumeration failure. Steps 3 and 4 it cannot enforce — that\nis the operator's honesty, and the run report is where it is spent.\n\n## Surface-dependent type taxonomies\n\nCAI does not have one asset-type taxonomy. It has two, and for a handful\nof Compute families they disagree:\n\n- the **list / export / query / monitor** surface (`gcloud asset list`,\n  this tool's primary sweep) splits a family by scope into **separate\n  asset types**;\n- the **search / analysis** surface (`gcloud asset\n  search-all-resources`) folds those same resources into a **single\n  unified type**.\n\nGoogle documents this per type on the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types),\nbut only in the per-type prose, which is easy to read past:\n\n> `compute.googleapis.com/Address` — Returns global and regional\n> addresses in the search and analysis APIs, and only regional addresses\n> in the list, export, query, and monitor APIs.\n>\n> `compute.googleapis.com/GlobalAddress` — Not available in the analysis\n> and search APIs. Use `compute.googleapis.com/Address` instead in the\n> search and analysis APIs.\n\nThe documented pairs:\n\n| Declared (search taxonomy) | List-surface sibling swept alongside it |\n|---|---|\n| `compute.googleapis.com/Address` | `compute.googleapis.com/GlobalAddress` |\n| `compute.googleapis.com/BackendService` | `compute.googleapis.com/RegionBackendService` |\n| `compute.googleapis.com/Disk` | `compute.googleapis.com/RegionDisk` |\n| `compute.googleapis.com/ForwardingRule` | `compute.googleapis.com/GlobalForwardingRule` |\n\n### Reproducing it, if you ever need to\n\nThe silent-gap condition needs a family whose declared type yields\n**non-zero** while still being incomplete: at least one REGIONAL address\nand at least one GLOBAL address, in the same in-scope project. With only\nglobal addresses present the declared type yields zero, the pre-existing\nzero-yield warning fires, and the tool is loud — a real bug, but not\nTHIS bug. (With several scopes, remember the aggregate warning can stay\nquiet while one scope's own declaration yielded nothing — the per-scope\n`_meta.scopes[].zero_yield_types` record is the loud one there.) A test estate built only from a freshly created global address\ntherefore validates that siblings are swept, and validates nothing about\nthe silence.\n\n### Why this one is worse than an unsupported type\n\nAn unsupported type fails loudly. This one fails **silently, past every\nguard the tool has** — which is why it needed a mechanism rather than a\nsharper error message. Live-run finding: a global Private Service\nConnect address never entered the denominator, because\n\n- the declared type IS supported by `asset list`, so the\n  unsupported-type fallback to `search-all-resources` never fired;\n- the sweep SUCCEEDED, so nothing landed in `SWEEP_FAILURES`;\n- it returned 33 regional addresses, so the zero-yield warning stayed\n  quiet;\n- the asset was never collected, so `apply_level_filter`'s `unknown`\n  safety net never saw it either.\n\nThe operator had already hand-written the correct `import {}` block, so\nthe only signal that fired was `coverage.py` reporting it as an **orphan\nimport block** — and an orphan was read as a coverage-map problem and\nwaived, rather than as evidence that the denominator was short.\n\n**An orphan import block for a resource you know is live is evidence the\ndenominator is short.** Investigate the enumeration before waiving it;\na waiver signed over a short denominator makes the gap permanent and\nattributed to a human.\n\n### What `inventory.py` does\n\n`CAI_SPLIT_TYPES` is a frozen table of the pairs above. Declaring the\nunified type sweeps its list-surface siblings too, and the siblings'\nentries are **retyped back to the declared type**, so one manifest line\nmeans \"all addresses\" — which is what an operator reading the\nsupported-types page will believe it means. This costs **zero extra API\ncalls**: `asset list` takes a comma-separated `--asset-types`, so the\nsiblings ride along in the existing sweep.\n\nNothing is laundered by the retyping:\n\n- each retyped entry carries `cai_list_type` with the type CAI actually\n  returned it as, and that field travels into `coverage.py`'s worklist;\n- `_meta.split_type_sweeps` records declared type, list-surface type,\n  scope and the RAW swept count — what CAI returned, before the\n  subtree/deleted/level filters;\n- collection prints a NOTICE naming every sibling that contributed, and\n  reconciles swept against in-the-denominator itself (e.g. `3 swept …,\n  2 in the denominator (1 excluded by scope/level/deleted filters)`) —\n  the filters apply to retyped entries like to any other asset, and the\n  subtraction is the tool's to show, not the reader's to derive.\n\nA sibling the manifest declares **in its own right** is not remapped: an\noperator who names `compute.googleapis.com/GlobalAddress` explicitly\nwants it accounted as itself, and remapping would make their\nper-declared-type yield read zero.\n\n### Checking the table against live CAI\n\nThe table is a frozen snapshot of a document Google changes. A new split\n— or a renamed pairing — would put the tool straight back into the\nfailure mode the table closes, so there is a probe:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra `search-all-resources` call per scope, restricted to declared\nsplit types. It compares against the **raw** sweep output, before the\nmanifest's subtree filters, because the question under test is taxonomy\nand not scope. Anything the search surface returns that the list sweep\ndid not is **fatal** (exit 3) with the offending resource names: the\ntable is stale and the denominator is incomplete.\n\nThe probe is opt-in, for two reasons: it costs a call per scope, and it\nimports the search index's propagation lag as a possible false positive.\nRun it at least once per engagement, and quote the result in the run\nreport. `_meta.split_parity` is **empty** when the probe did not run; a\nprobe that ran and found nothing is a RECORD whose `only_in_search` is\nempty. Read the record, not the key — \"not checked\" and \"checked, clean\"\nare different claims and must not be reported as the same one.\n\nIf the probe fires, identify the list-surface type of the missing assets\n(`gcloud asset list` with no `--asset-types`, then match on name) and\nreport it: `CAI_SPLIT_TYPES` needs a new entry, which is a reviewed\nchange to a frozen file, not a local patch.\n\nCAI **retiring** a split is the convergence this table is waiting for.\nA sibling type that `asset list` no longer recognises is therefore\nreported as a possibly-stale table, not as a failed run — a sibling is\ntool-supplied, not operator-declared, so it must never fail someone\nelse's collection.\n\n## Field-level blind spots\n\nA missing field in a CAI payload is asymmetric to an unsupported asset type:\n- An **unsupported type fails loudly**: CAI returns `INVALID_ARGUMENT`, the run\n  stops, and a remedy is displayed.\n- A **missing field fails silently**: `.get()` returns `None`, a filter\n  predicate answers \"no\", and the asset unexpectedly stays in or drops out\n  of the denominator while the run appears healthy.\n\nA filter predicate must never depend on a field whose presence in the live\npayload has not been verified. When a discriminator field is unavailable or\nunverified in CAI, the filter must **fail open** — keep the asset in the\ndenominator for a human to waive, never drop it silently.\n\n## Built-in enumerators\n\n| Asset type | How it is enumerated | Status |\n|---|---|---|\n| `iam.googleapis.com/DenyPolicy` | `gcloud iam policies list --kind=denypolicies --attachment-point=cloudresourcemanager.googleapis.com/<container>` | Command shape verified against gcloud 576; payload shape not yet exercised live |\n\nThe table is short on purpose. To qualify, an enumerator has to be\nhierarchy-container-scoped, read-only, JSON-emitting, and produce a key\nthat is stable and unique. Most non-CAI types fail one of those —\nusually the first — and stay in the blind-spot table below, enumerated\nby hand and named in the run report.\n\nThe table is frozen, like the rest of `inventory.py`: it decides part of\nwhat the denominator contains. Nothing about extending it requires\nediting a frozen file, though — a manifest block covers a type the table\ndoes not, and overrides an entry when an operator knows better. An\nenumerator earned in an engagement should come back as a reviewed change\nto the table, so the next run starts with it.\n\n## Declaring a native enumerator\n\nA manifest type entry may carry an `enumerate:` block. The type is then\nnever sent to CAI; the declared gcloud command runs once per in-scope\ncontainer at the type's `levels`, and its JSON becomes inventory\nentries:\n\n```yaml\n    types:   # a scope's own list — the only position types are declared in\n      # IAM deny policies: managed by modules/organization,\n      # modules/folder and modules/project, and absent from the CAI\n      # catalogue entirely.\n      - type: iam.googleapis.com/DenyPolicy\n        levels: [organization, folder]\n        enumerate:\n          # `gcloud` is implicit and is the only executable a manifest\n          # can name. The tool appends --format=json and one container\n          # argument.\n          command: [iam, policies, list, --kind=denypolicies]\n          # Optional: the shape of that container argument, when the\n          # command does not take --organization/--folder/--project=<id>.\n          # Fields: {container} (e.g. organizations/1) and\n          # {container_id} (1).\n          container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n          # Key template. Fields: {container}, {container_id} and\n          # {item.<dotted.path>} into each returned JSON object. Where\n          # the type has a CAI name format, mirror it, so entries from\n          # the two sources dedupe instead of double-counting.\n          key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n```\n\nDeclared in one scope's list, the command runs only for that scope's\ncontainers; another scope may declare the same type with a different\n`enumerate:` block. What it may not do is inherit: per-scope lists\nnever inherit anything, so the block is repeated in every scope that\nneeds it.\n\n**The mechanism is per hierarchy container**, because that is the shape\nof `levels` — the type entry's `levels` intersected with its scope's.\nAn empty intersection is refused when the manifest is parsed (a\nper-scope declaration that can never fire is a mistake, not a\nleftover), unless the entry lists `unknown`. A command that enumerates inside another\nresource — `gcloud storage managed-folders list gs://BUCKET` — cannot be\nexpressed as an `enumerate:` block: there is no container flag to fill\nin. Those types are enumerated out of band and named in the run report\n(step 3 of the ladder), or waived deliberately. Do not force them into a\nblock by hard-coding one bucket; a container argument that does not vary\nsweeps one place and leaves the rest silently unenumerated, which is why\nthe tool refuses it.\n\nRun the command by hand once before committing the block: the key\ntemplate has to match the payload the command actually returns, and a\nfirst run is the cheapest place to find out that it does not. A field\nthat is absent is a hard failure, and a yield of zero is reported\nloudly — neither is silent, but neither belongs in a run either.\n\nGuard rails, all fail-closed, all for the same reason — a native\nenumerator is a hand-written piece of the denominator, and the\ndenominator is the one thing in this skill nobody is allowed to shrink\nquietly:\n\n- the command must end in a read-only verb (`list`, `describe`, `get`,\n  `search`); anything else is refused before a single call is made;\n- `--format`, `--filter`, `--limit`, `--page-size`, `--flatten`,\n  `--sort-by` and `--uri` are refused. Narrowing belongs in the\n  manifest's `scope` block, where it is reviewed; a `--filter` inside an\n  enumerator shrinks the denominator with no trace;\n- the command may not select an identity — impersonation is set once for\n  the whole run via `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`;\n- a key template that renders the same key for two items, or that\n  references a field the payload does not have, is a hard failure. Two\n  assets collapsing onto one key is a shrunken denominator with a green\n  gate;\n- every native sweep is stamped into `inventory.json`'s `_meta`\n  (`native_sweeps`: type, verbatim command, container, yield) and\n  announced on stderr. A reviewer can re-run each command by hand.\n\nThe manifest is human-owned and committed at the scope-approval gate,\nwhich is what makes an operator-supplied command acceptable at all. An\nagent may draft one; a human signs it, exactly as with waivers.\n\n## Known or suspected gaps (verify per engagement)\n\n| Area | Concern | Mitigation |\n|---|---|---|\n| Service coverage | CAI supports several hundred asset types but not every GCP service/resource; niche or very new resources may be absent | Check the [CAI supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types) for every service the user cares about; for uncovered types, declare a native enumerator (above) so they still enter the denominator |\n| Wrong type string | A type string that does not exist in the catalogue is not a blind spot but a typo, and it presents as one. `gcloud asset list` answers `INVALID_ARGUMENT: No supported asset type matches: <type>` | `inventory.py` classifies that error separately from a permission failure and stops with instructions. Live example: `logging.googleapis.com/OrganizationSettings` does not exist — CAI models the Logs Router settings singleton as `logging.googleapis.com/Settings` (supported at `organization` and `folder` levels only; not indexed at `project` level) |\n| Right type string, wrong surface | A type that is correct for `search-all-resources` can cover only PART of its family under `asset list`, which splits some Compute families by scope into separate types. No error, non-zero yield, silent gap. Live example: a global PSC address typed `compute.googleapis.com/GlobalAddress` by the list surface, absent from a `compute.googleapis.com/Address` sweep | **Handled automatically** for the documented pairs — `CAI_SPLIT_TYPES` sweeps the siblings and accounts them under the declared type. Run `--verify-search-parity` once per engagement to check the frozen table against live CAI; see [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies) |\n| IAM deny policies | `iam.googleapis.com/DenyPolicy` is not a CAI asset type. Fabric manages deny policies at all three container levels (`iam_deny_policies`) | **Handled automatically** — built-in enumerator; just declare the type |\n| Log exclusions | `logging.googleapis.com/LogExclusion` is not a CAI asset type, and current `gcloud` has no `logging exclusions` group either — the ladder falls through to the REST API (`v2/{parent}/exclusions`). Fabric manages exclusions in `modules/organization`, `modules/folder` and `modules/project` | Enumerate out of band, map them, and record the enumeration method in the run report; do not let their absence from CAI read as absence from the estate |\n| Org-policy content-type lag | `--content-type=org-policy` can lag behind newly introduced v2 constraints; the `orgpolicy.googleapis.com/Policy` resource asset stream is more complete | `inventory.py` merges both CAI streams; keep cross-checking counts with `gcloud org-policies list` |\n| Org-policy dry-run specs | CAI `orgpolicy.googleapis.com/Policy` resource stream returns policies where `spec` is present (with or without `dryRunSpec`), but completely **omits dry-run-only policies** where `spec` is unset | `inventory.py collect` sweeps `gcloud org-policies list` per in-scope container and merges by key, so dry-run-only policies enter the denominator automatically |\n| IAM conditions | The `iam-policy` content type returns version-3 policies with conditional bindings intact | Verified in live testing |\n| IAM on leaf assets | `inventory.py` restricts the `iam` pseudo-type to container assets (`Organization`, `Folder`, `Project`); leaf IAM needs explicit per-type manifest entries (`iam: true`) | Documented in the manifest reference |\n| Audit configs | `auditConfigs` block is present and fully preserved in the CAI `iam-policy` payload | Verified in live testing |\n| Deleted / pending-delete resources | CAI reflects live state; soft-deleted roles or pending-delete projects may or may not appear | Decide policy per type; document in the run report |\n| Propagation lag | CAI can lag live changes by minutes | Re-run inventory immediately before the final gate pass; treat count mismatches with service APIs as failures, not noise |\n| Cloud Storage below the bucket | The Cloud Storage section of the CAI catalogue contains exactly one type, `storage.googleapis.com/Bucket`. Objects (`google_storage_bucket_object`), managed folders (`google_storage_managed_folder`) and managed-folder IAM are not modelled | `gcloud storage ls` / `gcloud storage managed-folders list` take a bucket URL, not a container flag, so they do not fit an `enumerate:` block. Enumerate per bucket out of band, map, and record the method in the run report |\n| Terraform-only resources | Some `google_*` resources correspond to no asset at all. `google_project_service_identity` triggers creation of a Google-managed service agent: the state row is real, the CAI asset is not, and Fabric's `modules/project` handles service agents internally | Map through the owning module and note it in the report; there is nothing to enumerate and nothing to waive |\n| Leaf IAM read as a missing type | `google_storage_bucket_iam_binding`, `google_tags_tag_value_iam_binding` and friends are not asset types. They are the `iam-policy` content type on an asset CAI already models, and enter the denominator via `iam: true` on the PARENT type entry | Never waive these as unsupported. `manifest_from_state.py` maps the common ones to their parent with the flag already set |\n| Data-plane / child resources | Some child resources (e.g. per-bucket notification configs, dataset ACL entries) are attributes of the parent in CAI, not assets | The plan gate covers them once the parent is imported; note them in the report if the user expects per-child coverage |\n| Access Context Manager | CAI `asset list --content-type=resource` rejects ACM types, requiring `asset search-all-resources`; `parentFullResourceName` lacks standard container prefixes | Handled in `inventory.py` via dedicated search and level classification |\n\n## Rules\n\n1. When a scope's `types:` list declares a type, confirm it appears in\n   the CAI supported-types list. If it does not, it gets a native enumerator, an\n   out-of-band enumeration recorded in the report, or a signed waiver —\n   one of the three, always. Never a quiet removal from the manifest.\n2. Any count mismatch between CAI and a service API is a **failure to\n   investigate**, never noise to average over. The same applies between\n   CAI's own two surfaces: `asset list` and `search-all-resources`\n   disagreeing about a type is a taxonomy split to identify, not a\n   discrepancy to pick a winner from.\n3. An **orphan import block** reported by `coverage.py` for a resource\n   you know is live is evidence the denominator is short. Investigate\n   the enumeration first; a waiver over a short denominator makes the\n   gap permanent and signs a human's name to it.\n4. Every entry that did not come from CAI is named in the run report,\n   with the command that produced it. `_meta.native_sweeps` in\n   `inventory.json` carries this for declared enumerators;\n   `_meta.split_type_sweeps` carries entries that came from CAI under a\n   different type than the one declared; out-of-band enumeration is on\n   you to quote. Per-scope yields live in\n   `_meta.scopes[].declared_types` / `zero_yield_types` — the aggregate\n   table can hide a scope whose own declaration yielded nothing.\n5. A scope's `types:` list narrows the denominator, so it belongs to\n   the Scope Approval gate like any other narrowing. A convergence\n   claim is bounded per scope, not per manifest — report it that way.\n6. New blind spots discovered during an engagement get added here (this\n   file is reference documentation, not a frozen tool).\n","skills/fabric-importer/references/inferring-manifests-from-state.md":"# Inferring Import Manifests from Terraform State\n\nWhen migrating or refactoring existing Google Cloud infrastructure managed by Terraform into Cloud Foundation Fabric (CFF), inferring the `import-manifest.yaml` directly from existing Terraform state file(s) (`.tfstate`) is the most precise and deterministic way to define the import boundary.\n\n---\n\n## Why State-Driven Manifest Generation?\n\n| Approach | Strength | Trade-off / Note |\n| :--- | :--- | :--- |\n| **State-Driven Inference (`manifest_from_state.py`)** | Exactly matches the existing Terraform management footprint. No risk of accidentally pulling unmanaged live assets into the stage scope. | Requires access to state files (`terraform state pull` or GCS bucket access). |\n| **Live Asset Survey (`inventory.py survey` + `manifest_init.py`)** | Discovers all live cloud assets, including unmanaged or out-of-band resources. | Broader denominator; requires manually waiving pre-existing or external assets. |\n\n---\n\n## Workflow\n\n### 1. Retrieve the Terraform State(s)\n\nPull the state files for the target stages or root modules:\n\n```bash\n# Example for FAST Stage 0\ngsutil cp gs://<iac-core-project>-iac-org-state/default.tfstate stage-0.tfstate\n\n# Or pull via Terraform CLI\nterraform -chdir=path/to/stage-0 state pull > stage-0.tfstate\n```\n\nFor multi-stage environments (e.g., FAST stages 0, 1, 2), you can pull states for all relevant stages:\n```bash\nterraform -chdir=fast/stages/0-org-setup state pull > stage-0.tfstate\nterraform -chdir=fast/stages/1-vpcsc state pull > stage-1.tfstate\nterraform -chdir=fast/stages/2-networking state pull > stage-2-networking.tfstate\nterraform -chdir=fast/stages/2-security state pull > stage-2-security.tfstate\n```\n\n### 2. Generate the Manifest\n\nRun `manifest_from_state.py`:\n\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate \\\n  --out import-manifest.yaml\n```\n\nTo synthesize a manifest across multiple stages (note `--force`: the\nmanifest is human-owned and gate-relevant, so it is never overwritten\nsilently — use `--out -` to review on stdout first):\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate stage-1.tfstate stage-2-networking.tfstate stage-2-security.tfstate \\\n  --out import-manifest.yaml --force\n```\n\nAll state files must belong to the same organization: a state spanning\nseveral is refused rather than silently resolved to one of them.\n\nWhen running on an isolated stage state (e.g. `stage-2-networking.tfstate`) that contains resources across multiple folders without including an organization resource in that state, specify the intended scope root with `--root`:\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-2-networking.tfstate \\\n  --root organizations/123456789012 \\\n  --out import-manifest.yaml\n```\n\n### 3. Review and Collect\n\nReview the generated `import-manifest.yaml`, then collect the live denominator:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\n---\n\n## Type & Level Mapping Reference\n\nThe inference script automatically maps Terraform `google_*` resources to CAI asset types and container levels:\n\n| Terraform Resource Type | Manifest `type` | Container Levels | Special Handling |\n| :--- | :--- | :--- | :--- |\n| `google_organization_iam_*` | `iam` | `[organization]` | Authoritative & additive bindings |\n| `google_folder_iam_*` | `iam` | `[folder]` | Folder IAM |\n| `google_project_iam_*` | `iam` | `[project]` | Project IAM |\n| `google_service_account_iam_*` | `iam.googleapis.com/ServiceAccount` | `[project]` | Emits `iam: true` on SA type |\n| `google_org_policy_policy`, `google_organization_policy` | `org-policy` | `[organization, folder, project]` | Inferred from parent attribute |\n| `google_organization_iam_custom_role` | `iam.googleapis.com/Role` | `[organization]` | Org custom roles |\n| `google_project_iam_custom_role` | `iam.googleapis.com/Role` | `[project]` | Project custom roles |\n| `google_logging_organization_sink` | `logging.googleapis.com/LogSink` | `[organization]` | Org log sinks |\n| `google_logging_folder_sink` | `logging.googleapis.com/LogSink` | `[folder]` | Folder log sinks |\n| `google_logging_project_sink` | `logging.googleapis.com/LogSink` | `[project]` | Project log sinks |\n| `google_logging_project_bucket_config`| `logging.googleapis.com/LogBucket` | `[project]` | Project log buckets |\n| `google_logging_organization_settings` | `logging.googleapis.com/Settings` | `[organization]` | Logs Router settings singleton (CAI has no `OrganizationSettings` type) |\n| `google_logging_folder_settings` | `logging.googleapis.com/Settings` | `[folder]` | Logs Router settings singleton |\n| `google_essential_contacts_contact` | `essentialcontacts.googleapis.com/Contact` | inferred from `parent` | Essential contacts |\n| `google_storage_bucket_iam_*` | `storage.googleapis.com/Bucket` | `[project]` | Emits `iam: true` on the bucket type |\n| `google_tags_tag_value_iam_*` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Emits `iam: true` on the tag value type |\n| `google_folder` | `cloudresourcemanager.googleapis.com/Folder` | `[organization, folder]` | Top-level and nested folders |\n| `google_project` | `cloudresourcemanager.googleapis.com/Project` | `[organization, folder]` | Projects |\n| `google_storage_bucket` | `storage.googleapis.com/Bucket` | `[project]` | GCS Buckets |\n| `google_service_account` | `iam.googleapis.com/ServiceAccount` | `[project]` | Service Accounts |\n| `google_tags_tag_key` | `cloudresourcemanager.googleapis.com/TagKey` | `[organization]` | Resource Manager Tag Keys |\n| `google_tags_tag_value` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Tag Values |\n| `google_tags_tag_binding` | `cloudresourcemanager.googleapis.com/TagBinding` | `[organization, folder, project]` | Tag Bindings |\n| `google_project_service` | `serviceusage.googleapis.com/Service` | `[project]` | Enabled APIs |\n| `google_access_context_manager_*` | `identity.accesscontextmanager.googleapis.com/*` | `[organization]` | Access Policies, Perimeters, Levels |\n| `google_compute_network` | `compute.googleapis.com/Network` | `[project]` | VPCs |\n| `google_compute_subnetwork` | `compute.googleapis.com/Subnetwork` | `[project]` | Subnets |\n| `google_compute_global_address` | `compute.googleapis.com/Address` | `[project]` | Global IP Addresses |\n| `google_compute_router` | `compute.googleapis.com/Router` | `[project]` | Cloud Routers & NAT |\n| `google_compute_firewall` | `compute.googleapis.com/Firewall` | `[project]` | Firewall rules |\n| `google_compute_firewall_policy` | `compute.googleapis.com/FirewallPolicy` | `[organization, folder]` | Hierarchical Firewall Policies |\n| `google_compute_network_firewall_policy` | `compute.googleapis.com/NetworkFirewallPolicy` | `[project]` | Network Firewall Policies |\n| `google_compute_ha_vpn_gateway`, `google_compute_vpn_gateway` | `compute.googleapis.com/VpnGateway` | `[project]` | HA & Classic VPN Gateways |\n| `google_compute_external_vpn_gateway` | `compute.googleapis.com/ExternalVpnGateway` | `[project]` | External VPN Gateways |\n| `google_compute_vpn_tunnel` | `compute.googleapis.com/VpnTunnel` | `[project]` | VPN Tunnels |\n| `google_dns_managed_zone` | `dns.googleapis.com/ManagedZone` | `[project]` | Cloud DNS Zones |\n| `google_dns_response_policy` | `dns.googleapis.com/ResponsePolicy` | `[project]` | Cloud DNS Response Policies |\n| `google_network_connectivity_hub` | `networkconnectivity.googleapis.com/Hub` | `[project]` | NCC Hubs |\n| `google_network_connectivity_spoke` | `networkconnectivity.googleapis.com/Spoke` | `[project]` | NCC Spokes |\n| `google_storage_managed_folder` | `storage.googleapis.com/ManagedFolder` | `[project]` | Storage Managed Folders |\n| `google_storage_managed_folder_iam_*` | `storage.googleapis.com/ManagedFolder` | `[project]` | Emits `iam: true` on ManagedFolder |\n| `google_kms_key_ring` | `cloudkms.googleapis.com/KeyRing` | `[project]` | KMS Key Rings |\n| `google_kms_crypto_key` | `cloudkms.googleapis.com/CryptoKey` | `[project]` | KMS Keys |\n| `google_bigquery_dataset` | `bigquery.googleapis.com/Dataset` | `[project]` | BigQuery Datasets |\n| `google_pubsub_topic` | `pubsub.googleapis.com/Topic` | `[project]` | Pub/Sub Topics |\n| `google_pubsub_subscription` | `pubsub.googleapis.com/Subscription` | `[project]` | Pub/Sub Subscriptions |\n| `google_secret_manager_secret` | `secretmanager.googleapis.com/Secret` | `[project]` | Secret Manager |\n| `google_iam_workload_identity_pool` | `iam.googleapis.com/WorkloadIdentityPool` | `[project]` | WIF Pools |\n| `google_iam_workload_identity_pool_provider` | `iam.googleapis.com/WorkloadIdentityPoolProvider` | `[project]` | WIF Identity Providers |\n\n### Types this table does not list\n\nA `google_*` type absent from the table is reported as unmapped, and the\nwarning is precise about what that means: **this tool has no static\nTerraform-to-CAI row for it**. It is not a statement about Cloud Asset\nInventory. Triage each one — CAI may model it (add the type by hand), it\nmay be leaf IAM on a type CAI models (`iam: true` on the parent), CAI may\ngenuinely not model it (`enumerate:` block or out-of-band enumeration,\nsee [cai-blind-spots.md](./cai-blind-spots.md)), or it may be deliberately\nout of scope (signed waiver). Anything you resolve is worth sending back\nas a `TF_TYPE_MAP` entry.\n\n---\n\n## Multi-Scope Partitioning\n\nWhen resources span both Organization/Folder governance and project-contained resources, `manifest_from_state.py` produces a multi-scope configuration:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: stage-projects\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111   # prj-prod-audit-logs-0\n      - projects/222222222222   # prj-prod-iac-core-0\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n```\n\nThis prevents project-level queries from accidentally scanning every unrelated project in the organization while still managing org-level policies and folders.\n\nEvery scope carries its own `types:` list; the generator filters the inferred types per scope (a type whose `levels` cannot fire at a scope's levels is left out, because `inventory.py` refuses dead per-scope declarations) but keeps each entry's full inferred `levels` — the intersection with the scope's `levels` happens at collect time, so the entry reads the same in every scope that carries it. Narrowing a generated list further — dropping a type or a level from one scope but not another — is a normal human refinement of the draft. Regenerating the manifest discards such refinements, so refine after generation, not before.\n","skills/fabric-importer/references/manifest-migration.md":"# Manifest migration — from the retired grammar to scopes-only\n\nThe import-manifest grammar changed once, and this file is the record of\nit. If `inventory.py` refused your manifest with *\"manifest declares\nretired top-level key(s)\"*, you are holding a manifest written for the\nold grammar; this page is the complete migration path.\n\n## What changed, and why\n\n**Old grammar (retired).** A manifest declared WHERE to look and WHAT to\ncollect in two separate places: a singular `scope:` (or a `scopes:`\nlist) for the subtrees, and one manifest-global `types:` list shared by\nevery scope. The only per-scope discrimination was the `levels` axis\n(organization / folder / project / unknown): a type declared anywhere\nwas swept in every scope whose levels intersected its own.\n\n**New grammar (current).** `scopes:` is the only form, and **every scope\ncarries its own `types:` list**. There is no top-level `types:` and no\nsingular `scope:`. What a scope collects is exactly what is written on\nit.\n\nThe motivation is per-scope type declarations — different resource\ntypes under different subtrees (org governance at org/folder level, the\nconnectivity stack under the networking folder, project IAM only in\nnamed workload projects), which the global list could not express\nwithout waiving every out-of-domain asset by hand. Making the new form\nthe ONLY form, rather than an option next to the old one, makes three\nwhole classes of silent-denominator failure unrepresentable instead of\nmerely validated: there is no inheritance to half-apply, no global list\nto go stale behind the scopes, and no mixed manifest whose meaning\ndepends on which scope the reviewer happened to read.\n\nThe tool refuses the old grammar rather than silently accepting half of\nit, because a half-read manifest would change what the denominator\nmeans — the exact failure the gates exist to prevent.\n\n## Migration: singular `scope:`\n\nWrap the scope as a one-element `scopes:` list and move the `types:`\nlist inside it (indent by four spaces). Nothing else changes — same\ntypes, same levels, same include/exclude, same collected denominator.\n\n```yaml\n# OLD (refused)                     # NEW\nscope:                              scopes:\n  root: organizations/123            - root: organizations/123\n  include:                             include:\n    - folders/456                        - folders/456\ntypes:                                 types:\n  - type: org-policy                     - type: org-policy\n    levels: [organization]                 levels: [organization]\n  - type: iam                            - type: iam\n    levels: [organization, folder]         levels: [organization, folder]\n```\n\n## Migration: `scopes:` list with a shared top-level `types:`\n\nCopy the top-level list into EVERY scope, then (optionally, later)\nnarrow each copy. The unnarrowed copy is semantically identical to the\nold manifest: the collect-time intersection of a type's `levels` with\neach scope's `levels` is unchanged, so the same assets enter the\ndenominator.\n\n```yaml\n# OLD (refused)\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\ntypes:\n  - type: iam\n    levels: [organization, folder, project]\n  - type: cloudresourcemanager.googleapis.com/Folder\n    levels: [organization, folder]\n\n# NEW — verbatim copy first, narrow later\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n```\n\nNote the second scope: `cloudresourcemanager.googleapis.com/Folder`\nwas NOT copied into it. A per-scope entry whose `levels` cannot\nintersect its scope's `levels` is refused as a dead declaration\n(`[organization, folder] ∩ [project] = ∅`), so a verbatim copy is only\nvalid where the type could actually fire. Under the old grammar that\nentry was silently inactive for the scope; now it is an error — drop\nit from the scopes it cannot fire in. Entries whose `levels` include\n`unknown` are exempt and may be copied everywhere.\n\nIf YAML repetition bothers you, anchors keep shared lists in one\nplace:\n\n```yaml\nscopes:\n  - name: teams\n    root: organizations/123\n    include: [folders/111]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: rest\n    root: organizations/123\n    exclude: [folders/111]\n    types: *folder-types\n```\n\n## Key mappings, old → new\n\n| Old | New |\n|---|---|\n| `scope:` (singular) | `scopes:` with one entry |\n| top-level `types:` | each scope's own `types:` (copy, then narrow) |\n| top-level `emission:` (manifest-wide default) | `scopes[].emission` on each scope that wants a non-default style; an omitted family falls back to the built-in default (`per-instance`; `additive` for `iam`), not to another scope |\n| `enumerate:` block, declared once | declared in the `types:` list of every scope that needs the sweep — per-scope lists never inherit, so the block is repeated per scope (a different scope may even carry a different block for the same type) |\n| `iam: true` leaf opt-in, declared once | per scope, like everything else on a type entry: a scope that omits it collects the resource without its leaf IAM |\n| type silently inactive in a scope (empty levels intersection) | refused as a dead declaration (drop the entry from that scope; `unknown` entries exempt) |\n\n## What did NOT change\n\n- The type entry schema: `type`, `levels`, `iam`, `enumerate` are\n  unchanged, including all `enumerate:` guard rails.\n- Scope fields: `name`, `root`, `include`, `exclude`, `levels` are\n  unchanged, including numeric-id resolution for include/exclude.\n- Collect-time semantics for a faithful copy: same sweeps, same\n  intersection rule, same deduplicated denominator. A migrated\n  manifest that copies the old list into every (compatible) scope\n  yields the same `assets` — diffing old and new `inventory.json`\n  asset keys is the cheap proof.\n- Gates and waivers: `coverage.py` and `verify_plan.py` are untouched;\n  existing workspaces, coverage maps and waiver ledgers keep working.\n\n## New output you should start reading\n\n- `_meta.scopes[].declared_types` / `zero_yield_types` — per-scope\n  yield tables. The aggregate `_meta.declared_types` cannot show a type\n  that yields zero in one scope while non-zero in another; the\n  per-scope record (and its stderr warning) can. Quote the per-scope\n  tables in the step-5 report.\n- Every inventory entry carries `scopes: [...]` — the scope(s) that\n  collected it, merged and sorted when scopes overlap.\n\n## Migration checklist\n\n1. Rewrite the manifest as above (or regenerate:\n   `manifest_from_state.py` and `manifest_init.py` emit the new\n   grammar directly).\n2. Run `inventory.py collect` — validation is fail-closed and runs\n   before any API call, so a wrong migration is a refused manifest\n   with the reason, never a shrunken denominator.\n3. Diff the new `inventory.json` asset keys against the last\n   pre-migration one. Identical keys = faithful migration. Missing\n   keys = you narrowed while migrating; deliberate narrowing belongs\n   in its own reviewed manifest change, not folded into the migration.\n4. The manifest is human-owned: the migrated file goes through the\n   same Scope Approval gate as any other manifest edit.\n\nWorked references: `examples/import-manifest.org-foundation.yaml`\n(single scope) and `examples/import-manifest.multi-domain.yaml` (four\nscopes, per-scope types). The fail-closed rules live in SKILL.md\nstep 0 and the cookbook's \"Per-scope type declaration\" subsection.\n","skills/fabric-importer/references/mapping-cookbook.md":"# Mapping cookbook — live GCP to Cloud Foundation Fabric\n\nReview-hardened knowledge from the live verification rounds summarised in\n[COVERAGE.md](../COVERAGE.md). Every rule here encodes a bug that was\nactually made and caught; provenance markers like \"(verified r9)\" point\nat the round that proved the rule. Read the section for a resource type\nBEFORE mapping it. When in doubt, read the Fabric module source in this\nrepository — never trust memory for module internals.\n\n**This document is deliberately incomplete, and always will be.** It is\nnot a specification of what the skill supports; it is the precipitate of\na method — the accumulated residue of mapping real resources against a\nlive organization and keeping what survived the gates. A type with no\nsection here is not unsupported. It means nobody has run the method on it\nyet.\n\nSo entries are born the same way every time, and that is the only way\nthey should be born: someone maps a live resource by reading the module\nsource, converges both gates, and writes down what cost them a cycle. See\n\"Step 3b — when the cookbook has never seen this type\" in\n[SKILL.md](../SKILL.md) for the loop. **If you map a type that has no\nsection here, adding one is part of your run report** — that is what\nkeeps this file growing rather than the same traps being paid for on\nevery engagement.\n\nWhat belongs in an entry: the CAI asset-type string as live output\nreturns it, the module addresses and `for_each` key formats, the import\nID format, the ForceNew inputs that must mirror live values, and every\ntrap that cost a cycle. Mark surfaces you did not exercise as unverified\n— an honest gap is worth more than an implied guarantee.\n\nContents: canonical module map · factory emission (opt-in) · universal\nrules · workspace layout · scaffolding scripts · import-ID table ·\nper-type rules (organization, folders, projects, service accounts, log\nsinks, logging buckets, VPC networks, root module) · fallback and\naccelerator.\n\n**Where the fielded data lives.** Address patterns, import IDs, CAI\nasset types and verification rounds are held in\n[`address-map.yaml`](./address-map.yaml) and rendered into the\nimport-ID table below; the prose here owns everything else — the traps,\nthe causal reasoning and the capability gaps. Add a new address to the\nYAML, not to the table, and run\n`uv run scripts/address_map.py` to check both.\n\n## Canonical module map (normative)\n\nFabric modules are the product — this table is the default mapping, not\na suggestion. Raw resources require a documented module capability gap\n(see \"Fallback\" at the end).\n\nEmission has two modes — `per-instance` (the default: one module\ninstance per container, inputs as HCL) and `factory` (data-driven YAML\nconsumed by a Fabric factory carrier). The mode is chosen per resource\nfamily in the manifest's `emission:` block (see \"Factory emission\n(opt-in)\" below); families with `—` in the factory column have exactly\none mode and ignore the knob. Some sub-resources are factory-only by\nFabric convention regardless of the knob: org policies, custom roles\nand custom constraints are carried as `data/` YAML inside their owning\n`organization` / `folder` / `project` instance.\n\n| Scope / resource family | Default (`per-instance`) | `factory` emission — carrier | Carries |\n|---|---|---|---|\n| Organization | `modules/organization` (single instance) | — | org IAM + audit configs, org policies (factory), custom roles (factory), org sinks |\n| Folder | `modules/folder` — one instance per folder | `project-factory` — hierarchy `data/`, one YAML dir per folder, path = key | the folder itself, folder IAM, folder org policies (factory), folder sinks |\n| Project | `modules/project` — one instance per project | `project-factory` — one project YAML per project | the project, services, project IAM, project org policies, metadata |\n| Service accounts | `modules/iam-service-account` | `project-factory` — `service_accounts:` block in the owning project's YAML (only when that project is factory-emitted) | SA + its IAM |\n| Log buckets | `modules/logging-bucket` | — | bucket config (sink destinations) |\n| VPC networks | `modules/net-vpc` | subnets only: `net-vpc` `factories_config.subnets_folder` | network, subnets (incl. proxy-only), routes |\n| VPC firewall rules (legacy) | `modules/net-vpc-firewall` | `net-vpc-firewall` `factories_config.rules_folder` | custom rules + the four `default_rules_config` rules |\n| Cloud NAT & Routers | `modules/net-cloudnat` | — | Cloud NAT + its Cloud Router (NAT-only routers) |\n| HA VPN | `modules/net-vpn-ha` | — | HA VPN gateway, external gateway, tunnels, its own Cloud Router, interfaces, BGP peers |\n| Cloud DNS | `modules/dns` | — (no factory: recordsets are the `recordsets` input, keyed `\"<type> <name>\"`) | managed zones, recordsets |\n| DNS Response Policy | `modules/dns-response-policy` | `dns-response-policy` `factories_config.rules` | response policies & rules |\n| NCC | `modules/ncc-spoke-ra` (hub + router-appliance spokes only) | — | hub; linked-VPC/VPN spokes are a capability gap |\n| Firewall policy rules | `modules/net-firewall-policy` inputs | `net-firewall-policy` `factories_config` rule files | ingress/egress rules |\n| Other | nearest Fabric module for the family; check `modules/` in-repo | see `FACTORIES.md` at the repo root — the authoritative factory catalog (every module factory flows through `factories_config`) | — |\n\nInstance keys: pick short stable slugs at first import (record them in\n`coverage-map.yaml`); keys are immutable afterwards. Because the key —\nnot the display name — defines the address, a console rename plans as\nan in-place `name` update, not destroy/create (verified r6).\n\n## Factory emission (opt-in)\n\n**Status: cookbook-level (C) — rules below are derived from module\nsource in this repository, not yet exercised against live imports.**\nEverything in this section meets that standard and no higher; treat\nunmarked claims as unverified.\n\nThe manifest owns the choice, per resource family. `emission` is a\nmap of family → style, where family names are the canonical-map rows\nand styles are family-specific: `per-instance` (default) or `factory`\nfor module-instance families (those without a factory carrier reject\n`factory`), and `additive` (default) or `authoritative` for the `iam`\npseudo-family (see \"Organization and containers: IAM\"). It lives on\nthe scope entry, like everything else in the manifest: the grammar is\nscopes-only, and there is no top-level position.\n\nResolution, per family:\n\n```text\nscopes[].emission.<family>  →  built-in default (per-instance / additive)\n```\n\nOmission means the built-in default — for `emission` only. **Do not\ngeneralise this to `types:`.** Both keys sit on a scope entry, and\nthere the resemblance stops: `emission` is denominator-neutral (it\ndecides how collected assets are written, and a missing family key\nfalls back safely), while `types:` decides what is collected at all —\nevery scope must declare its full list, and an omission is a refused\nmanifest, not a default. A wrong emission choice still faces both\ngates; a wrong type list shrinks the denominator the gates are\nmeasured against.\n\n```yaml\nscopes:\n  - name: org-foundation\n    levels: [organization, folder]\n    # types: … (each scope carries its own list — elided here)\n    emission:\n      folder: factory        # -> project-factory hierarchy data/\n\n  - name: sandbox\n    levels: [folder, project]\n    include: [folders/333333333333]\n    # types: …\n    # no emission: -> built-in defaults (per-instance; iam additive)\n```\n\n### Subtree-granular emission (scope splitting)\n\nEmission granularity follows scope granularity: to manage *some*\nfolders via factory and others per-instance, split the tree into\nscopes along subtree boundaries and give each its own `emission:` —\nscope `include`/`exclude` already speak subtrees (CAI `ancestors`\nmatching), so no new selector machinery is needed. Remember the\ncoupling: every scope carries its own `types:` list, so a scope added\nfor emission reasons declares its list like any other (usually a copy\nof the list it split from):\n\n```yaml\nscopes:\n  - name: teams               # this subtree is factory-managed\n    root: organizations/000000000000\n    include: [folders/111111111111]\n    levels: [folder]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n    emission:\n      folder: factory\n\n  - name: rest                # everything else stays per-instance\n    root: organizations/000000000000\n    exclude: [folders/111111111111]\n    levels: [folder]\n    types: *folder-types      # same list; YAML anchors avoid drift\n```\n\nRules that make the split sound (from `project-factory` source,\n`folders.tf`):\n\n- **Factory subtrees can be rooted mid-tree.** A level-1 factory\n  folder takes an explicit `parent` in its `.config.yaml`\n  (`parent = coalesce(<config parent>, \"$folder_ids:default\")`),\n  resolvable through `context.folder_ids` — so a factory-managed\n  subtree may sit under a per-instance-managed or unmanaged folder.\n- **One factory instance for the whole workspace.** All\n  factory-emitted folders share a single `project-factory` instance\n  and one `data/` hierarchy, regardless of how many scopes opted in:\n  disjoint subtrees become multiple top-level `data/` directories,\n  each with an explicit `parent`. The factory is workspace-shaped,\n  not scope-shaped — scopes decide what is collected, emission how it\n  is written.\n- **Boundary crossings never use literals.** Downward\n  (per-instance child under a factory folder):\n  `parent = module.<pf>.folder_ids[\"<path>\"]`. Upward (factory\n  subtree under a per-instance folder): `parent:` in the subtree\n  root's `.config.yaml`, fed via the factory's `context.folder_ids`\n  input. Every style boundary is a seam: name it in the run report.\n- **The 4-level cap applies per factory subtree**, since each subtree\n  root is level 1 — splitting deep hierarchies along scope boundaries\n  also relieves the nesting-depth tradeoff.\n\nWhen to opt in: the imported workspace is the intended day-2 operating\nmodel and the family's ongoing management is data-shaped — the\ncanonical case is foundational infrastructure, with the folder tree,\nfolder IAM and org policies maintained as hierarchical YAML. When in\ndoubt, stay per-instance; graduating a family to factory emission later\nis a `moved {}` exercise, per instance, at a time of your choosing.\n\n### Tradeoffs (why per-instance stays the default)\n\n- **Key = data path, blast radius = subtree.** In `project-factory` the\n  hierarchy directory path IS the instance key: renaming or moving a\n  directory re-keys every folder and project below it — a\n  destroy/create plan across the subtree. The same immutability rule as\n  instance keys applies (choose the `data/` layout once, record it in\n  `coverage-map.yaml`), but the coupling is structural, not per-key.\n  D-04 recovery applies unchanged if it ever happens.\n- **Deeper, internal addresses.** Import blocks target factory\n  internals — these are not a stable interface across Fabric refs, so\n  \"read the module source in this repository, do not trust memory\" does\n  double duty here. Re-verify address shapes on every Fabric ref bump.\n- **Nesting depth.** `project-factory` supports at most 4 folder\n  levels (hardcoded `module.folder-1` … `module.folder-4`); the\n  per-instance `modules/folder` chain has no limit. A hierarchy deeper\n  than 4 cannot be factory-emitted.\n- **The reference rule changes carrier.** Inside factory YAML,\n  references are context interpolations (`$folder_ids:<path>`,\n  `$iam_principals:…`), not module outputs. The rule itself is\n  unchanged — no literals for managed resources — only its spelling:\n  - **Level-1 folders** (under the organization or a boundary parent):\n    `parent: \"organizations/<org_id>\"` or `parent: \"$folder_ids:default\"`.\n  - **Nested folders (Level 2+)**: `parent: \"$folder_ids:<parent_path>\"`\n    (e.g. `parent: \"$folder_ids:networking\"` in `networking/production/.config.yaml`).\n    While `project-factory` defaults omitted `parent` on child folders to\n    `\"$folder_ids:${parent_key}\"`, emitting it explicitly makes hierarchy\n    dependencies clear and self-documenting in the YAML data.\n\n### project-factory: addresses and layout (from module source)\n\n- Folders: `module.<pf>.module.folder-<level>[\"<path>\"]` where\n  `<level>` is 1-based directory depth and `<path>` the `data/`-relative\n  directory path. Folder IAM lives on a **separate** paired instance,\n  `module.<pf>.module.folder-<level>-iam[\"<path>\"]` — both must appear\n  in `coverage-map.yaml` for a folder with IAM.\n- Projects: `module.<pf>.module.projects[\"<key>\"]`; service accounts:\n  see `projects-service-accounts.tf` for the key format.\n- Cross-emission references: the factory outputs `folder_ids` /\n  `project_ids` / `iam_principals` keyed by hierarchy path, so mixed\n  mode composes without literals — a per-instance project under a\n  factory folder takes `parent = module.<pf>.folder_ids[\"<path>\"]`.\n  The inverse (factory projects under per-instance folders) goes\n  through the factory's `context.folder_ids` input.\n- Escaping: the factory-YAML rows of the escaping table below apply\n  wholesale; check `templatestring` usage per field with the same\n  `grep -rn templatestring modules/project-factory/` discipline.\n\nOther carriers (`net-vpc` subnet factory, `net-firewall-policy` rule\nfiles, …): same method — read the carrier's `factories_config` source,\nderive address + key formats, and add a subsection here as part of the\nrun report, marking what was exercised. `FACTORIES.md` at the repo root\nis the authoritative catalog of factory carriers.\n\n## Universal rules\n\n### Imports and coverage\n\n- Every emitted resource gets a paired `import` block in the owning\n  instance's `*-import.tf`, and an entry in `tf/coverage-map.yaml`.\n- Deterministic keys: map keys become Terraform addresses; derive them\n  from content, never from list position. For additive IAM use\n  `slug(role)_slug(member)`, with\n  `_sha256(role\\0member\\0title\\0expression)[:8]` appended when\n  conditional; for authoritative conditional bindings use\n  `slug(role)_slug(title)_sha256(role\\0title\\0expression)[:8]`.\n- Run `terraform fmt -recursive` as the last emission step;\n  `terraform fmt -check -recursive` must pass before the gates. Fmt-ed\n  output is the canonical form the determinism and checksum assertions\n  compare (verified plan-invariant, r11).\n\n### Escaping\n\nWhat to escape depends on two things: the carrier (HCL input or factory\nYAML) and whether the module passes the field through\n`templatestring()`. Check with\n`grep -rn templatestring modules/<module>/` — do not guess.\n\n| Field | Carrier | Templated? | Escape `${` as |\n|---|---|---|---|\n| IAM condition expressions (org, folder, project modules) | HCL input | yes | `$$${` |\n| Org-policy values / condition fields / parameters (all levels) | factory YAML | yes | `$${` |\n| Members, titles, filters, descriptions (non-templated inputs) | HCL input | no | `$${` |\n\n`%{` follows the same pattern with `%`. Escape the `${` and `%{`\nsequences only — never blanket-replace `$`: in HCL, `$$` not followed\nby `{` is two literal dollars, so broad replacement corrupts values\n(caught in r7 scaffolding review). Literal `${` round-trip is verified\nend to end (r3, seeded condition title).\n\n### The reference rule\n\nIf a referenced resource is managed in the workspace, refer to it\nthrough its module output — never by literal string:\n`project_id = module.project-<key>.project_id`,\n`parent = module.folder-<key>.id`, `module.sa-<key>.iam_email` for\nmembers where the SA is managed. Literals are only for references that\nleave the managed graph (a billing account, an unimported destination),\nand every such literal is an \"unmanaged reference\" line in the run\nreport. When a referenced resource later graduates into management,\nupgrading the literal to a module output is part of that import's\ncommit (verified r10: sink destinations). Two deliberate exceptions\nstay literal: import block `id`s and `coverage-map.yaml` — they are\njoin artifacts, and determinism and auditability win.\n\n### Scope semantics\n\n- References never widen scope. Managing config that references an\n  unmanaged resource is fine — org sinks converge without their\n  destinations, IAM bindings without their SA principals — as long as\n  convergence does not depend on the referenced side and the report\n  names it. The manifest decides scope.\n- Scopes narrow on two axes: subtrees (`include`/`exclude`) and what\n  is collected inside them (`levels`, and each scope's own `types:`\n  list). Both are Scope-Approval-gate surface: they move the\n  denominator.\n- Manifest `include` / `exclude` match the CAI `ancestors` array,\n  which stores numeric ids. Both spellings work: `projects/123456789`\n  (the project NUMBER) matches directly, and a project ID is resolved\n  to its number by `inventory.py` via `gcloud projects describe` —\n  prefer the number where known, since resolution needs the describe\n  permission. See `examples/import-manifest.org-foundation.yaml` for\n  the include-vs-exclude trap and\n  `examples/import-manifest.multi-domain.yaml` for selecting specific\n  projects per scope.\n\n#### Per-scope type declaration (`scopes[].types`)\n\nEvery scope carries its own `types:` list — there is no other place\nto declare types, so what a scope collects is exactly what is written\non it. The same type may appear in several scopes with different\n`levels`, `iam` or `enumerate`; an `enumerate:` block is swept only\nin the scopes whose list names it.\n\nFail-closed rules (all refuse the manifest before any API call):\n\n- a scope without a `types:` list, or with `types: []`, is refused —\n  an empty list is a scope that collects nothing, and that is the one\n  thing no scope may say quietly;\n- a type whose `levels` cannot intersect its scope's `levels` is\n  refused as a dead declaration (an entry listing `unknown` is\n  exempt): it reads as coverage and produces none;\n- a duplicate `type:` inside one scope's list is refused.\n\nProvenance in `inventory.json`: each `_meta.scopes[]` entry records\nits own `declared_types` yield counts and `zero_yield_types`;\n`_meta.declared_types` is the aggregate; every inventory entry names\nthe scope(s) that collected it (`scopes: [...]`, merged and sorted\nwhen scopes overlap). **Read the per-scope counts, not only the\naggregate** — a type that yields zero in one scope and non-zero in\nanother never appears in the aggregate zero-yield warning. Same\ndiscipline as `_meta.split_parity`: read the record, not the key.\n\n### Serialization traps\n\n- YAML emission: quote YAML 1.1 booleans (`yes/no/on/off`), preserve\n  multi-line strings verbatim (block style, no rstrip), never sort\n  ordered lists.\n- The API omits false booleans in JSON output (`includeChildren` absent\n  means `false`, not `true`).\n- Empty string vs null — **check which of the two shapes you are facing\n  before reaching for a benign rule** (r17):\n\n  1. **Module passes the variable through directly**\n     (`description = var.description`, with a non-empty variable\n     `default`). Setting the input explicitly to `null` round-trips a\n     live empty or unset value cleanly, because Terraform treats an\n     explicit null as \"attribute not present\" and plans no diff.\n     Verified on `modules/bigquery-dataset` (r17): a live dataset with\n     `description = \"\"` planned `\"\" -> \"Terraform managed.\"` until\n     `description = null` was set, after which it imported clean. **This\n     is the preferred fix — a config change, not a rule.**\n  2. **Module wraps the field in `coalesce(var.x, default)`.** Terraform's\n     `coalesce` skips null AND empty strings, so the default always\n     wins and no config value can express \"empty\". `null` does nothing\n     here. This is the only shape where a `when_before`-guarded benign\n     rule is warranted — D-03, verified on org log sinks\n     (`modules/organization/logging.tf` uses\n     `coalesce(each.value.description, \"...\")`).\n\n  Tell them apart with\n  `grep -n '<field>' modules/<module>/*.tf`. Reaching for a rule when\n  shape 1 applies means shipping a benign entry that a one-line config\n  change would have removed. Never an unguarded attribute rule in\n  either case.\n- ForceNew inputs: for every module, check which inputs are ForceNew on\n  the underlying resource and mirror live values exactly BEFORE the\n  first plan. A destroy/create pair on an imported resource is always a\n  mapping error. Known cases: network/subnet `description` (r9), log\n  bucket `parent` format (r10).\n- **Silently dropped module inputs.** Terraform's object type conversion\n  DISCARDS attributes a module's variable type does not declare — with\n  no error and no warning. `terraform validate` returns Success. So a\n  field you believe you have set may never reach the resource, and from\n  the plan alone that looks identical to a provider artifact. Verified\n  against `modules/certificate-authority-service` before #4107: passing\n  `publishing_options` produced a clean validate and the destructive\n  removal diff regardless.\n\n  Diagnosis rule: when a diff persists on an attribute you are certain\n  you have set, read the module's variable TYPE before concluding\n  anything (`grep -A20 'variable \"<name>\"' modules/<module>/variables.tf`).\n  A silently-dropped input is the mechanical tell for a module\n  capability gap, and it is the single easiest way to mistake a gap for\n  benign drift.\n\n## Workspace layout (normative)\n\nOne file per container instance (organization, folder, project) and a\ndata tree that mirrors the hierarchy using instance keys as path\nsegments — never display names, so renames touch nothing and\nreparenting is a cosmetic `git mv`:\n\n```\ntf/\n├── versions.tf  organization.tf  organization-import.tf\n├── folder-<key>.tf / project-<key>.tf     # one per CONTAINER, + -import.tf\n├── coverage-map.yaml\n└── data/\n    ├── organization/{org-policies,custom-roles}/*.yaml\n    └── folders/<key-a>/org-policies/…            # top-level folder\n        └── <key-b>/org-policies/…                # child nested under parent\n            └── projects/<proj-key>/org-policies/…\n```\n\nProject-contained resources live in their project's file. Every module\ninstance whose resource lives inside a project — service accounts,\nbuckets, VPCs, datasets — goes in that project's `project-<key>.tf`\n(import blocks in `project-<key>-import.tf`), not in a file of its own.\nA project file is the complete, reviewable inventory of that project.\nOnly containers get their own files (owner decision after r8).\n\nFile placement is address-neutral (verified r11: plan-invariant).\nRe-homing a module block between files changes no address, no state, no\ncoverage-map entry — so re-layouts are always safe and do not violate\nincremental immutability, which governs addresses and instance keys,\nnever file placement. Do re-layouts as dedicated, mapping-free commits.\n\nEach instance's `factories_config` points at exactly its own subtree.\nModule-input config (IAM, sinks) lives in the instance's `.tf` file;\nonly factory-driven YAML lives under `data/`. The same constraint at\ndifferent levels lands on different paths — no collisions.\n\n## Scaffolding scripts (run-local generators)\n\nWriting a helper script to emit the workspace is legitimate — with\ngovernance:\n\n1. Workspace-confined and org-confidential: scripts embed org data;\n   they live in the workspace (or its private repo), never in the\n   skill. Parameterize by construction — org id and container ids come\n   from the manifest, `inventory.json` or argv, never hardcoded.\n2. The cookbook is the spec; the script is an implementation. A\n   divergence is a defect even while gates are green, because gates\n   only verify paths the live org exercises today (r7 review found four\n   such latent divergences).\n3. Scope comes from the frozen denominator: the set of emitted things\n   joins against `inventory.py` keys. Detail fetches (`describe`,\n   `get-iam-policy`) are fine.\n4. Deterministic: sorted iteration, stable keys, double-run\n   byte-identical, `terraform fmt` as the last step.\n5. No silent promotion: durable knowledge becomes cookbook prose;\n   durable mechanism goes through human review into a tested frozen\n   tool. Never \"the script works, keep it\".\n6. Reported: the run report lists scaffolding used and gate verdicts;\n   commit the script as round evidence (sanitized and parameterized).\n7. Strict address matching: When filtering or mutating import blocks\n   across incremental runs, match full resource type strings (e.g.\n   `google_organization_iam_custom_role`) rather than loose substrings\n   (like `\"custom_role\"`), which collide with role IDs such as\n   `roles/latchkey_custom_role_automation`.\n\n## Import-ID quick table\n\n**Generated — do not edit by hand.** The rows below are rendered from\n[`address-map.yaml`](./address-map.yaml), which owns the fielded subset\nof this cookbook: asset type, module, address pattern, import ID and the\nround that verified it. Edit the YAML and re-render:\n\n```bash\nuv run scripts/address_map.py --render-cookbook\n```\n\nTwo invariants are machine-enforced there and cannot be restated here by\naccident: an address pattern is claimed by exactly one entry, and a CAI\nasset type serviced by more than one module must say how to tell the\ncarriers apart. `<placeholder>` segments are chosen per engagement.\nA blank `Verified` cell means the surface was never exercised live —\nan honest gap, not an implied guarantee.\n\n<!-- BEGIN ADDRESS-MAP (generated) -->\n\n| Resource | Module | Address pattern | Import ID | Verified |\n|---|---|---|---|---|\n| BigQuery Dataset | `modules/bigquery-dataset` | `module.<instance>.google_bigquery_dataset.default` | `projects/<project_id>/datasets/<dataset_id>` | r17 |\n| CAS CA Pool | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool.default[0]` | `projects/<project_id>/locations/<location>/caPools/<name>` | r14 |\n| CAS CA Pool IAM | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role>` | — |\n| CAS CA Pool IAM (cond.) | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>` | — |\n| Certificate Authority | `modules/certificate-authority-service` | `module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]` | `projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>` | — |\n| DNS zone | `modules/dns` | `module.<instance>.google_dns_managed_zone.dns_managed_zone[0]` | `projects/<project_id>/managedZones/<zone>` | r12 |\n| DNS recordset | `modules/dns` | `module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]` | `projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>` | — |\n| DNS Response Policy | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy.default[0]` | `projects/<project_id>/responsePolicies/<response_policy_name>` | — |\n| DNS Response Policy rule | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]` | `projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>` | — |\n| Folder | `modules/folder` | `module.folder-<key>.google_folder.folder[0]` | `folders/<id>` | r6 |\n| Folder IAM | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]` | `folders/<id> <role>` | r6 |\n| Folder IAM (cond.) | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]` | `folders/<id> <role> <condition_title>` | r6 |\n| Folder IAM member (additive, default) | `modules/folder` | `module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]` | `folders/<id> <role> <member>` | — |\n| Folder sink | `modules/folder` | `module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]` | `folders/<id>/sinks/<name>` | r6 |\n| Folder org policy | `modules/folder` | `module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `folders/<id>/policies/<constraint>` | r6 |\n| GCS bucket | `modules/gcs` | `module.<instance>.google_storage_bucket.bucket[0]` | `<project_id>/<bucket_name>` | r12 |\n| Service account | `modules/iam-service-account` | `module.sa-<key>.google_service_account.service_account[0]` | `projects/<project_id>/serviceAccounts/<email>` | r8 |\n| SA IAM binding | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/serviceAccounts/<email> <role>` | r8 |\n| SA IAM binding (cond.) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <condition_title>` | r8 |\n| SA IAM member (additive, default) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <member>` | — |\n| KMS CryptoKey | `modules/kms` | `module.<instance>.google_kms_crypto_key.default[\"<key>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>` | r13 |\n| KMS CryptoKey IAM | `modules/kms` | `module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>` | r13 |\n| KMS KeyRing | `modules/kms` | `module.<instance>.google_kms_key_ring.default[0]` | `projects/<project_id>/locations/<location>/keyRings/<name>` | r13 |\n| Log bucket | `modules/logging-bucket` | `module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]` | `projects/<project_id>/locations/<location>/buckets/<name>` | r10 |\n| NCC Hub | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_hub.hub[0]` | `projects/<project_id>/locations/global/hubs/<hub_name>` | — |\n| NCC spoke (router appliance) | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_spoke.spoke_ra` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | — |\n| Compute address (global PSA) | `modules/net-address` | `module.<instance>.google_compute_global_address.psa[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (global PSC) | `modules/net-address` | `module.<instance>.google_compute_global_address.psc[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (regional internal) | `modules/net-address` | `module.<instance>.google_compute_address.internal[\"<key>\"]` | `projects/<project_id>/regions/<region>/addresses/<name>` | r19 |\n| Cloud NAT | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router_nat.nat` | `projects/<project_id>/regions/<region>/routers/<router>/<nat>` | r12 |\n| Cloud Router | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | r12 |\n| Hierarchical Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_firewall_policy.hierarchical[0]` | `locations/global/firewallPolicies/<id>` | r7 |\n| Global Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_network_firewall_policy.net_global[0]` | `projects/<project_id>/global/firewallPolicies/<name>` | r7 |\n| Regional Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]` | `projects/<project_id>/regions/<region>/firewallPolicies/<name>` | r7 |\n| Interconnect Attachment | `modules/net-vlan-attachment` | `module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]` | `projects/<project_id>/regions/<region>/interconnectAttachments/<name>` | — |\n| VPC network | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_network.network[0]` | `projects/<project_id>/global/networks/<name>` | r3 |\n| VPC route (gateway) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ilb) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (instance) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ip) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (vpn tunnel) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC subnet | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r3 |\n| VPC subnet (private NAT) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | — |\n| VPC subnet (proxy-only) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r9 |\n| VPC firewall rule (custom) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]` | `projects/<project_id>/global/firewalls/<rule_name>` | — |\n| VPC firewall rule (default, admins) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_admins[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-admins` | — |\n| VPC firewall rule (default, http) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_http[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http` | — |\n| VPC firewall rule (default, https) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_https[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https` | — |\n| VPC firewall rule (default, ssh) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_ssh[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh` | — |\n| External VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]` | `projects/<project_id>/global/externalVpnGateways/<name>` | — |\n| HA VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]` | `projects/<project_id>/regions/<region>/vpnGateways/<name>` | — |\n| Cloud Router (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | — |\n| Cloud Router interface (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]` | `<project_id>/<region>/<router_name>/<interface_name>` | — |\n| Cloud Router BGP peer (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>` | — |\n| VPN Tunnel | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/vpnTunnels/<name>` | — |\n| Org audit config | `modules/organization` | `module.organization.google_organization_iam_audit_config.default[\"<service>\"]` | `<org> <service>` | r3 |\n| Custom role | `modules/organization` | `module.organization.google_organization_iam_custom_role.roles[\"<id>\"]` | `organizations/<org>/roles/<id>` | r3 |\n| Org IAM binding (authoritative opt-in) | `modules/organization` | `module.organization.google_organization_iam_binding.authoritative[\"<role>\"]` | `<org> <role>` | r3 |\n| Org IAM binding (cond.) | `modules/organization` | `module.organization.google_organization_iam_binding.bindings[\"<key>\"]` | `<org> <role> <condition_title>` | r3 |\n| Org IAM member (additive, default) | `modules/organization` | `module.organization.google_organization_iam_member.bindings[\"<key>\"]` | `<org> <role> <member>` | — |\n| Org log sink | `modules/organization` | `module.organization.google_logging_organization_sink.sink[\"<name>\"]` | `organizations/<org>/sinks/<name>` | r3 |\n| Org policy | `modules/organization` | `module.organization.google_org_policy_policy.default[\"<constraint>\"]` | `organizations/<org>/policies/<constraint>` | r5 |\n| Tag Key | `modules/organization` | `module.organization.google_tags_tag_key.default[\"<short_name>\"]` | `tagKeys/<id>` | r18 |\n| Tag Value | `modules/organization` | `module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]` | `tagValues/<id>` | r18 |\n| Tag Binding | `modules/organization` | `module.<instance>.google_tags_tag_binding.binding[\"<key>\"]` | `tagBindings/<url-encoded-parent-and-value>` | r18 |\n| PAM entitlement | `modules/project` | `module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]` | `<parent>/locations/<location>/entitlements/<entitlement_id>` | — |\n| Project | `modules/project` | `module.project-<key>.google_project.project[0]` | `projects/<project_id>` | r4 |\n| Project IAM | `modules/project` | `module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]` | `<project_id> <role>` | r4 |\n| Project IAM (cond.) | `modules/project` | `module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]` | `<project_id> <role> <condition_title>` | r4 |\n| Project IAM member (additive, default) | `modules/project` | `module.project-<key>.google_project_iam_member.bindings[\"<key>\"]` | `<project_id> <role> <member>` | — |\n| Project org policy | `modules/project` | `module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `projects/<project_id>/policies/<constraint>` | — |\n| Project service | `modules/project` | `module.project-<key>.google_project_service.project_services[\"<api>\"]` | `<project_id>/<api>` | r4 |\n| Project service (orgpolicy) | `modules/project` | `module.project-<key>.google_project_service.org_policy_service[0]` | `<project_id>/orgpolicy.googleapis.com` | r4 |\n| Workload Identity Pool | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>` | r18 |\n| Workload Identity Pool Provider | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>` | r18 |\n| Pub/Sub Topic | `modules/pubsub` | `module.<instance>.google_pubsub_topic.default` | `projects/<project_id>/topics/<topic_name>` | r17 |\n| VPC-SC Access Level | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]` | `accessPolicies/<policy_id>/accessLevels/<level_name>` | r15 |\n| VPC-SC Access Policy | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_policy.default[0]` | `<policy_id>` | r15 |\n| VPC-SC Service Perimeter | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]` | `accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>` | r15 |\n| NCC spoke (raw — capability gap) | raw | `google_network_connectivity_spoke.<instance>` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | r12 |\n\n<!-- END ADDRESS-MAP -->\n\nVerified across r2–r19 (see COVERAGE.md for the per-family evidence).\nFor anything else: check the provider documentation's import section, or\nemit the import block with the CAI asset name minus the\n`//<service>.googleapis.com/` prefix — verified to match the provider\nimport ID byte-for-byte on every family tested (r5 oracle, 100%) — and\nlet plan tell you: it errors loudly and safely on a wrong ID.\n\n## Per-type rules\n\n### Organization: org policies\n\n- Factory files in `data/.../org-policies/` wrap each policy in a map\n  keyed by the constraint name (`<constraint>: {rules: [...]}`) — the\n  module merges raw YAML via `yamldecode()` (verified r5).\n- `dryRunSpec` is real config: emit it under a `dry_run:` block in the\n  same constraint definition. A dry-run-only policy must never become\n  `rules: []` (verified r4; CAI omits dry-run-only policies — the\n  inventory service-API sweep exists for exactly this).\n- `parameters` must be a JSON string (the module feeds it to\n  `templatestring()`), serialized deterministically\n  (`json.dumps(..., sort_keys=True)`) (verified r3).\n- Factory `fileset()` is non-recursive: filenames are\n  `<constraint>.yaml`, never subdirectories.\n- Value lists are ordered attributes: preserve live order (verified r3).\n- **Verbatim condition expressions**: Condition expressions in org policy\n  rules returned by the API can carry a trailing newline (`\\n`). In factory\n  YAML, use block scalar style (`|`) rather than quoted/stripped strings so\n  the trailing newline is preserved and does not plan as an in-place update.\n\n### Organization: custom roles\n\n- Factory configuration:\n  `factories_config = { custom_roles = \"<path>\" }`.\n- Factory YAML schema: `includedPermissions: [...]` (matching the IAM\n  API attribute), `title`, `description`, `stage` (verified r5).\n- Title/description/stage need a Fabric ref containing PR #4102\n  (released in `v58.0.0`); on `v57.0.0` and earlier they plan as\n  in-place updates (D-01).\n\n### Organization and containers: IAM\n\n- **Additive is the default emission for IAM** (manifest\n  `emission.iam`, styles `additive` | `authoritative`, same two\n  positions and resolution as every other emission family). Each live\n  (role, member, condition) tuple maps to one `iam_bindings_additive`\n  entry → one `google_*_iam_member.bindings[\"<key>\"]`. Why default:\n  an apply can only create or destroy the exact tuples emitted — it\n  can never remove members it does not manage — and it is the\n  non-authoritative posture Google's PAM documentation recommends.\n  The honest tradeoff: additive emission cannot converge out-of-band\n  *additions* away — unmanaged members accumulate invisibly to the\n  plan, while coverage still counts the container policy as mapped.\n  Estates that want IAM fully declarative as the day-2 model opt into\n  `emission.iam: authoritative` (`iam` / `iam_bindings` maps — the\n  emission every existing verified round exercised); the choice is\n  the human's, recorded in the manifest.\n- Assert IAM policy `version: 3` when conditions exist — v1/v2\n  responses flatten conditions, which is silent privilege escalation.\n- `deleted:` principals cannot be imported; exclude them from bindings\n  but always surface them in the report (under additive emission the\n  tombstones simply stay unmanaged; under authoritative emission an\n  apply would remove them).\n- **PAM grant bindings are runtime state, never configuration —\n  excluded from the denominator by `inventory.py`, not by you.** On\n  grant activation, Privileged Access Manager injects a temporary\n  time-bound conditional binding into the container's allow policy and\n  revokes it itself when the grant ends; Google's PAM documentation\n  explicitly warns against managing these bindings in Terraform.\n  Whenever IAM is collected, `inventory.py` also sweeps active grants\n  through CAI (`privilegedaccessmanager.googleapis.com/Grant` — CAI\n  models the Grant, not the Entitlement; one extra call per scope,\n  covered by the same `cloudasset.viewer` grant) and strips matching\n  bindings before normalization. Matching is deterministic — (target,\n  role, requester) come from the grant resource itself, never from\n  string-matching the binding's condition, whose format Google does\n  not publish — and deliberately narrow: conditional bindings only, so\n  a permanent binding coinciding with a grant is kept. A container\n  whose policy holds only grant bindings mints no `#iam` entry:\n  structural exemption, not a waiver. Stripped bindings are stamped in\n  `_meta.pam_grant_exclusions`; quote them in the run report's\n  machine-managed exclusions. At emission time, work from the stamped\n  list — if a live policy shows a conditional binding that looks\n  PAM-shaped but is not in the stamp, the inventory is stale:\n  re-collect, do not classify by hand. Exclusion is also\n  collision-safe: under the additive default nothing unmanaged can be\n  removed by construction, and even under authoritative opt-in Fabric\n  `iam` maps produce bindings authoritative for the (role,\n  no-condition) tuple while PAM bindings always carry a condition, so\n  an apply of the emitted config cannot revoke an active grant. Declaring the Grant type in a manifest is refused. Do not\n  confuse grants with PAM *entitlements*, which are ordinary importable\n  configuration mapping to the `pam_entitlements` factory on\n  `organization` / `folder` / `project`.\n- Audit configs map to `logging_data_access` on `modules/organization`\n  (verified r3).\n- Conditional binding import IDs need a non-empty condition title;\n  format is space-separated:\n  `<org_or_folder_or_project_id> <role> <condition_title>`.\n- **Multi-module state consolidation**: When inferring from Terraform\n  state where IAM for the same scope/role is split across multiple module\n  instances (e.g. `module.organization`, `module.organization-iam`, or\n  dynamic factory loops): under authoritative emission, member lists\n  must be union-merged across all matching state bindings before\n  mapping into canonical module `iam` maps; under additive emission,\n  deduplicate (role, member, condition) tuples across instances\n  instead — each tuple is emitted exactly once.\n- **Verbatim condition expressions & ForceNew**: IAM condition expressions\n  from live API policies frequently contain a trailing newline (`\\n`).\n  Because `condition.expression` on `google_*_iam_binding` is `ForceNew`,\n  stripping whitespace or omitting the newline triggers a spurious\n  destroy/create (replacement) plan. Preserve the exact string from the live\n  API (e.g. via HCL heredoc `<<-EOT` or explicit `\\n`).\n\n### Folders (`modules/folder`, one instance per folder)\n\n- Chain parents by module reference\n  (`parent = module.folder-<parent-key>.id`; top-level folders point at\n  the org). No nesting-depth limit — unlike factory emission, which\n  caps at 4 levels (see \"Factory emission (opt-in)\").\n- Set `deletion_protection = true` (imported provider default; avoids a\n  spurious in-place change — verified r2).\n- Folder-level org policies, sinks and IAM live on the same instance as\n  the folder — never a separate wrapper.\n- Display-name renames are plain `name` updates (verified r6).\n- Re-keying recovery (D-04) — only if instance keys themselves must\n  change, which should be never. If a plan ever shows a destroy/create\n  pair on the same folder id:\n  1. Stop — never let that plan stand, even unapplied.\n  2. Rename the factory path/key to the new slug (content unchanged).\n  3. Add `moved { from = <old address> to = <new address> }` at the\n     workspace root and keep the block permanently.\n  4. Update the `coverage-map.yaml` entry's address in the same commit\n     (the one sanctioned exception to address immutability — the key\n     stays, the address moves with the `moved` block as evidence).\n  5. Re-plan: the pair must collapse to no-op or update before anything\n     else proceeds. Automatic `moved {}` generation is deliberately\n     deferred until a real engagement needs it at scale.\n\n### Projects (`modules/project`, one instance per project)\n\n- Chain parent by module reference or the org id for top-level\n  projects.\n- Set `deletion_policy = \"PREVENT\"`. `auto_create_network = true`\n  matched live state with zero diff on the pilot projects — confirm per\n  project via plan rather than assuming (r7).\n- `services`: the enabled-service list from the live ServiceUsage API.\n  `orgpolicy.googleapis.com` maps internally to\n  `google_project_service.org_policy_service[0]`; every other service\n  maps to `google_project_service.project_services[\"<api>\"]` (r7).\n- CAI can report ghost services (disabled in ServiceUsage but present\n  in CAI): cross-check `gcloud services list` and waive with evidence\n  (r7).\n- `service_agents_config`: set `{ create_primary_agents = false,\n  grant_default_roles = false, grant_service_agent_editor = false }` on\n  imported projects to avoid unwanted resource creation or default role\n  injection (r7).\n- `labels`: pass `{}` or live labels. Provider-computed\n  `terraform_labels` diffs are covered by a `when_before`-guarded\n  benign rule (r7).\n\n### Service accounts (`modules/iam-service-account`) — verified r8\n\n- Manifest: `iam.googleapis.com/ServiceAccount` with `iam: true` —\n  without the opt-in, impersonation grants (IAM on the SA) are\n  invisible to the denominator.\n- Google-managed and service-agent SAs (`*-compute@developer.`,\n  `*@cloudservices.`, `service-*@gcp-sa-*`) must be waived, never\n  imported — they are lifecycle-bound to services, not user config.\n- SA keys: do not import or enumerate user-managed keys (secrets);\n  waive and note in the report.\n- Reference rule: `project_id = module.project-<key>.project_id`;\n  impersonation members that are themselves managed SAs use\n  `module.sa-<key>.iam_email`; unmanaged principals stay literal and\n  are listed in the report.\n\n### Log sinks (org/folder level)\n\n- Never import `_Default` / `_Required` (Google-managed; automatically excluded from the denominator by `inventory.py`).\n- The module `type` enum is `gcs` (not `storage`), `bigquery`,\n  `logging`, `pubsub`, `project` — map the destination API prefix\n  accordingly; fail on an unknown prefix, never rewrite (r7).\n- `destin\n\n<!-- [TRUNCATED AT 50KB] -->","skills/fabric-importer/references/operating-contract.md":"# fabric-importer — operating contract\n\nRules for any agent or human operating this skill.\n\n## 1. Trust boundary\n\nThe skill is intentionally lean: the model performs discovery-to-Terraform\nmapping itself, and correctness comes from un-gameable verification, not\nfrom deterministic codegen. That only holds if the boundary is respected:\n\n| Layer | Contents | Who may change it |\n|---|---|---|\n| Frozen scripts | everything in `scripts/`, rulesets included (`benign-drift.yaml`) | Humans, via code review only. The model may run them, never edit them. |\n| Human-owned run files | `import-manifest.yaml`, `waivers.yaml` | Humans. The model drafts/proposes; a human commits. |\n| Model workspace | `tf/`, `coverage-map.yaml`, reports, worklists | The model — with immutable-address discipline (§4). |\n\nA converged verdict is only meaningful if the gates and their rulesets\nwere not touched by the same actor whose output they verify.\n\nBoth gates stamp runtime provenance into their output, covering every\nverification script. To check a recorded verdict, run `uv run\nscripts/integrity.py` from a clean checkout of the same commit and\ncompare; `--verbose` identifies which file differs. This is evidence,\nnot prevention — an actor editing a gate could edit the stamp — but it\nremoves the silent case, and any stored gate output can be checked after\nthe fact.\n\nThe script provenance alone does not prove which *inputs* a gate judged,\nso both gates also stamp every input they read — resolved path and\nSHA256 — on the line after the digest: the plan JSON (or stdin), the\nrules file actually loaded, the inventory, the coverage map, the waiver\nledger, and the set of `*.tf` files scanned. A non-default `--rules`\nfile additionally prints a loud warning and downgrades a converged\nverdict to exit code 3, so a substituted ruleset can never produce a\npassing gate — otherwise\npointing the gate at a permissive file would be a cheaper route to green\nthan editing the frozen file this contract forbids editing. A recorded verdict is therefore attributable to\nexact inputs, not just to a tool build.\n\nThe gates also fail closed on degenerate inputs: a plan without\n`format_version` and `resource_changes` (state JSON, `{}`), a plan with\nzero resource changes without `--allow-empty-plan`, and an empty\ninventory without `--allow-empty-inventory` all exit non-zero. An empty\nverdict is never evidence of convergence.\n\n**Which decisions need a human.** The pattern behind this table is that\nevery human-owned artifact either narrows what the gates check or is\nirreversible. Scope narrows the denominator — on two axes, since each\nscope carries its own `types:` list, and the tool refuses the shapes\nwhere that narrowing would be invisible (a scope without a list, an\nempty list, a declaration whose levels cannot match its scope), because\neach of those would shrink the denominator while still exiting 0. A\nwaiver removes an entry from the denominator; a benign rule removes an\nentry from the residual set; an apply\ncannot be undone. Everything else — module choice, file layout, instance\nkeys, raw-vs-module calls — is falsifiable by the plan and can be\nmodel-owned. Use that test when something new appears: *does this shrink\nwhat is checked, or is it irreversible?* If yes, a human decides.\n\nThe manifest is human-owned for that reason, and it is why a manifest is\nthe only place an operator may declare a native (non-CAI) enumerator —\nin the `types:` list of each scope that needs it, swept only in that\nscope, since per-scope lists never inherit.\nCloud Asset Inventory does not model every GCP resource, and a type it\ncannot see is invisible to both gates at once, so such types are\nenumerated with a read-only `gcloud` command and merged into the same\ndenominator (`references/cai-blind-spots.md`). `inventory.py` ships a\nfrozen table of these for types known to be absent from the catalogue\nand applies them automatically — that is tool-owned, reviewed code, and\na model may no more edit it than any other frozen ruleset. A manifest\nblock covers a type the table does not, or overrides one. That command\nwidens what is checked rather than narrowing it, but it is still a\nhand-written piece of the denominator: `inventory.py` refuses non-read-only verbs, refuses\n`--filter`/`--limit` and friends, refuses a key template that is not\nunique, and stamps every sweep it ran into `inventory.json`'s\n`_meta.native_sweeps`. A model may draft the block; a human commits it.\n\nWaivers therefore carry an optional `signed_by` field. It is reported by\ndefault and enforced with `coverage.py --require-signed-waivers`.\nEnforcement is opt-in on purpose: demanding attribution where no human is\npresent produces invented signatures, and a forged name defeats the audit\nit was meant to support.\n\n## 2. Absolute invariants\n\n1. **NEVER run `terraform apply`** — against any environment, for any\n   reason. The generated authoritative IAM bindings make an apply\n   equivalent to overwriting live org IAM with a stale snapshot.\n2. **Read-only against GCP APIs.** Allowed: `list`, `describe`,\n   `get-iam-policy`, `search`, asset inventory reads, plus\n   `terraform init/validate/plan/show`. Never\n   `set/add/remove/update/create/delete`. Prefer a dedicated read-only\n   identity so the rule is enforced by IAM, not prose.\n3. **Never synthesize Terraform state.** Plan JSON assertions\n   (`verify_plan.py`) are the only accepted convergence evidence.\n4. **Org-confidential output.** Workspace contents and anything derived\n   from a real org (IDs, domains, principals, resource names, counts)\n   never enter a PUBLIC repository, an upstream pull request, or a\n   shareable document unsanitized. Fabric is used fork-and-own, so\n   during the OWN phase these artifacts legitimately live in the\n   operator's own private fork alongside the code — that is the expected\n   working directory, and the boundary to police is publication, not the\n   filesystem. Pulled `.tfstate` and saved `*.tfplan` are the exception\n   in kind, not degree: they contain secret VALUES (Secret Manager\n   payloads, generated keys) and are deleted once the manifest is\n   drafted and the plan verified.\n5. **Never rationalize residual diffs.** A diff believed benign becomes a\n   *proposed* `benign-drift.yaml` entry with evidence, reviewed by a\n   human. Until accepted, the run is red.\n\n## 3. Convergence definition\n\nA run is converged when, for every manifest-declared scope:\n\n1. `coverage.py` exits 0 — every in-scope inventory key is mapped to\n   emitted import blocks or human-waived; and\n2. `verify_plan.py` exits 0 — every planned change is a clean import,\n   no-op, or matches a reviewed benign-drift rule.\n\nBoth are required. The manifest bounds the claim, scope by scope:\nconvergence says nothing about types or levels a scope did not declare,\nand each scope declares its own list — so the run report must state the\nbound per scope, not as a single sentence about \"the manifest\".\n\n## 4. Incremental discipline\n\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable; re-runs extend the workspace for worklist entries only.\n- Genuine re-keying (renames, refactors) requires `moved {}` blocks and a\n  prominent note in the run report.\n- Manifest changes only ever widen or narrow scope; they never license\n  rewriting existing mappings.\n\n## 5. Toolchain\n\nTerraform >= 1.5; google + google-beta providers `>= 7.40, < 8`; Fabric\nmodule refs pinned to released tags (surface unreleased-feature\ntrade-offs to the user instead of silently pinning SHAs). Provider or\nFabric ref bumps invalidate `benign-drift.yaml` verification stamps and\nthe cookbook's address table — re-verify before trusting a verdict.\n","skills/fabric-importer/scripts/benign-drift.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED benign-drift ruleset for verify_plan.py.\n#\n# Every entry declares a class of plan diffs that is a provider/schema\n# artifact rather than real config-vs-reality drift. Entries require:\n#   - resource:        terraform resource type (\"*\" for any)\n#   - attributes:      top-level attribute paths covered\n#   - reason:          human-reviewed justification\n#   - verified_against: provider (and optionally terraform) version the\n#     justification was checked on. Re-verify on provider major bumps.\n#   - a bound on the DIFF (machine-enforced; an unbounded rule is\n#     ignored with a warning when it would otherwise have applied):\n#       when_after: {attr: value}          exact destination value\n#       when_after_matches: {attr: regex}  destination matches, one line\n#       when_after_computed: [attr, ...]   destination is FULLY unknown at\n#         or true                          plan time (provider-computed)\n#       when_unchanged: [attr, ...]        these attributes must not\n#                                          appear in the diff at all\n#     Every attribute in `attributes` needs a direct destination guard,\n#     OR the rule declares `when_unchanged` naming the live-affecting\n#     siblings that make the diff inert (the `terraform_labels` case:\n#     provider bookkeeping is harmless exactly while `labels` and\n#     `effective_labels` are untouched). `when_unchanged` must be\n#     disjoint from `attributes`.\n#     `when_before` alone is NOT sufficient. A rule that bounds only the\n#     live value accepts an arbitrary destination, so the gate would\n#     report CONVERGED for a plan that really writes to the resource on\n#     apply — the exact silent-pass this ruleset exists to prevent.\n#   Only the diff bound is machine-enforced; `reason` and\n#   `verified_against` are conventions a human reviewer upholds.\n#\n# Keep `attributes` as narrow as possible: changed paths are reported as\n# TOP-LEVEL keys, so listing a block attribute waives every future change\n# anywhere under it.\n#\n# The model operating the skill MUST NOT edit this file. verify_plan.py\n# prints a proposal template for unmatched updates; a human reviews the\n# diff and commits the entry.\n\nrules:\n  - resource: google_folder\n    attributes: [timeouts]\n    when_before:\n      timeouts: {}\n    when_after:\n      timeouts: null\n    reason: >-\n      Google provider v7 emits `timeouts {} -> null` in the import preview\n      for imported folders; the attribute is not a live setting and\n      disappears once state is persisted.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_logging_organization_sink\n    attributes: [description]\n    # Guard: only sinks whose LIVE description is the empty string are\n    # covered; a real description drift on a sink must stay residual.\n    when_before:\n      description: \"\"\n    # Guard: the destination must be the module default, which\n    # modules/organization/logging.tf builds as\n    # `coalesce(each.value.description, \"${each.key} (Terraform-managed).\")`.\n    # Any other new description is real drift and stays residual.\n    when_after_matches:\n      description: '[^\\n]{1,96} \\(Terraform-managed\\)\\.'\n    reason: >-\n      Live GCP log sinks with empty description (\"\") cannot round-trip through\n      Fabric's coalesce(v.description, default) which treats empty string as null\n      and applies the default description.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_compute_subnetwork\n    attributes: [send_secondary_ip_range_if_empty]\n    when_before:\n      send_secondary_ip_range_if_empty: null\n    when_after:\n      send_secondary_ip_range_if_empty: true\n    reason: >-\n      Fabric modules/net-vpc hardcodes send_secondary_ip_range_if_empty = true\n      on google_compute_subnetwork to ensure secondary range removals propagate,\n      which causes an in-place preview diff (null -> true) on imported subnets.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: \"*\"\n    attributes: [terraform_labels]\n    when_before:\n      terraform_labels: {}\n    # Guard: terraform_labels is provider bookkeeping, harmless exactly\n    # while the attributes that really carry labels to the API are\n    # untouched. If `labels` or `effective_labels` move too, this is a\n    # real label write and stays residual.\n    when_unchanged: [labels, effective_labels]\n    reason: >-\n      Google provider v7 computes `terraform_labels: {} -> {...}` during import\n      preview when default/inherited provider labels exist while\n      effective_labels and labels match live state with zero underlying mutation.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n","skills/fast/networking/SKILL.md":"---\nname: fast-networking\ndescription: Designs, customizes, and validates Cloud Foundation Fabric FAST Stage 2 (2-networking) YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a 2-networking dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a 2-networking dataset.\n---\n\n# FAST `2-networking` Dataset Designer\n\nThis skill guides users from functional networking and security requirements—or an attached reference diagram/IP table—to a complete, schema-validated Cloud Foundation Fabric FAST Stage 2 (`fast/stages/2-networking`) YAML dataset in a custom directory.\n\n## Core Principles & Execution Rules\n\n> [!IMPORTANT]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):**\n> For all design and customization tasks, prepend the following 3-phase progress block to **every** response so the user always sees the current phase and active step (if the user only asks to validate an existing dataset on disk, run `scripts/validate_dataset.py` directly and report the results):\n> ```text\n> FAST 2-Networking Designer Progress:\n> - Phase 1: Requirements Discovery\n>   (Step 1/2: Baseline & VPC Footprint - IN PROGRESS)\n> - Phase 2: Topology, IPAM & Plumbing Proposal\n>   (Not started)\n> - Phase 3: Dataset Generation & Validation\n>   (Not started)\n> ```\n\n> [!IMPORTANT]\n> **Understanding Turn Boundaries & Conditional Questioning (`ask_user` / `ask_question` / `question`):**\n> - Once you ask the user a question or present a specification for confirmation, **stop execution immediately (do not call more tools)**.\n> - Never simulate, guess, or assume the user's answer in the same turn. Wait for the user's next message.\n> - **NEVER ask a meta-question about how the user wants to start:** Never ask whether the user wants an interview vs. diagram import vs. cloning a dataset. If the user did not attach a diagram or name an existing dataset in their prompt, start **Turn 1** of the interview immediately (you may mention in one line of prose that they can share a diagram or dataset name at any time).\n> - **NEVER bundle dependent questions in the same turn or question tool call:** Only mutually **independent** questions may be asked together. You **MUST** resolve whether the user wants a **Single Shared VPC** vs. **Multi-VPC Hub & Spokes** *before* asking any questions about spoke-to-spoke routing, NCC hubs, peering, or inter-VPC transit NVAs.\n> - **Concrete Options Only:** Every multiple-choice option must be self-contained and actionable. Never offer a vague option like *\"Single region (e.g., europe-west8 or europe-west1)\"* that forces an extra follow-up turn just to ask *which* single region they meant; list the specific common region choices directly and allow custom free-text input.\n\n1. **Step-by-Step by Default, Adaptive on Rich Input:**\n   - **Default Mode (Conditional Interview DAG):** When the user starts with a general request (e.g., *\"Help me define my FAST networking dataset\"*), follow the Conditional Interview DAG in `references/1-requirements-to-topology.md`, asking only the questions that apply to the user's chosen branch (`2` turns for Single Shared VPC; `3` turns for Multi-VPC Hub & Spokes) and recommending sensible FAST defaults.\n   - **Adaptive Mode (Diagram, IP Table, or Detailed Spec Provided):** When the user provides an architecture diagram (PNG/SVG/PDF/Draw.io), an IP table, or a detailed prompt upfront, extract all stated facts immediately, mark **Phase 1** completed, jump straight to **Phase 2 (Topology, IPAM & Plumbing Proposal)** with pre-filled defaults for any unspecified CIDRs/ASNs, and ask for a single sign-off before generating files.\n   - **Validation-Only Mode:** When the user points to an existing dataset directory and asks to validate or test it, run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the findings.\n   - **Zero-Redundancy Rule:** Never ask the user to repeat a region, CIDR, environment name, connectivity choice, or workload type they already provided in their prompt or diagram.\n\n2. **Stage 2 Boundary (\"Plumbing vs. Downstream Consumption\"):**\n   - `fast/stages/2-networking` provisions **foundational network plumbing only**: networking host projects, VPCs, subnets (primary, secondary, `proxy_only`, `psc`, `private_nat`), `psa_configs`, reserved addresses, Cloud Routers, Cloud NAT, VPC Peering, NCC hubs/spokes, HA VPNs, Interconnect VLAN attachments, NVA instances/ILBs/BGP spokes, Cloud DNS zones/response policies, and hierarchical/network/VPC firewall policies.\n   - Higher-level security and application resources—such as **Cloud NGFW Enterprise** (`firewall_endpoint` and `security_profile_group`, typically managed in a `2-networking-ngfw` or security stage), **Application Load Balancers** (`net-lb-app-*`), **GKE clusters**, **Cloud SQL / AlloyDB / NetApp instances**, and **PSC Producer `ServiceAttachment`s**—are deployed in **follow-up stages** (`2-project-factory` or workload/security stages).\n   - Whenever the user requests one of these capabilities, configure the required Stage 2 plumbing (e.g., `/23` `proxy_only: true` subnets, `psc: true` subnets, `psa_configs`, `$security_profile_groups:` rules in `firewall-policies/`) and record the downstream handoff in the generated dataset's `README.md`. Never invent unsupported YAML keys inside `2-networking`.\n\n3. **Custom Dataset Output Target:**\n   - Never overwrite the in-tree reference datasets under `fast/stages/2-networking/datasets/` unless explicitly developing/updating Fabric itself.\n   - Always generate the user's dataset into a dedicated custom directory (e.g., `./fast-config/2-networking/data/` or a user-specified path) along with a ready-to-use `2-networking.auto.tfvars` file that maps `factories_config` to that directory.\n\n4. **Mandatory Mechanical Validation Gate:**\n   - Never declare a generated or modified dataset complete until you have run `scripts/validate_dataset.py` against the target directory (checking JSON schemas, `$token` reference integrity, intra-VPC and cross-VPC CIDR overlaps, and routing/security guardrails, plus `terraform validate` and `terraform plan` when Terraform is available).\n\n---\n\n## Workflow Map\n\nBefore executing a phase, read its corresponding reference document for the decision tables, YAML patterns, and guardrails.\n\n### Phase 1: Requirements Discovery\n*Description:* Walk the Conditional Interview DAG (or extract facts from an attached diagram/prompt), forking cleanly between **Single Shared VPC** and **Multi-VPC Hub & Spokes**.\\\n*Reference: [Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- **Step 1:** Stage 0 Integration, Concrete Target Regions & Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`)\n- **Step 2:** Branch-Specific Requirements Discovery (`Step 2` Single-VPC Plumbing on Path A; `Step 2a` Spoke Scope/Inspection + `Step 2b` Hybrid/Egress/Plumbing on Path B)\n\n### Phase 2: Topology, IPAM & Plumbing Proposal (Single Sign-Off Gate)\n*Description:* Map the functional requirements to a **Single Shared VPC** layout or one of the canonical `2-networking` hub-and-spoke topologies (`ncc`, `nva-ncc`, `nva`, `peerings`, `vpns`), pre-fill sensible non-overlapping default CIDRs, BGP ASNs, DNS zones, and Stage 2 plumbing in a single unified blueprint table, and ask the user once to confirm (or tweak) the proposal and output directory.\\\n*References:*\n- *[Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- *[Routing and Security Mechanisms](references/2-routing-and-security-mechanisms.md)*\n- *[Stage Boundaries and Plumbing](references/3-stage-boundaries-and-plumbing.md)*\n- *[Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 3:** Unified Topology, Pre-Filled Regional IPAM/BGP/DNS Defaults, Output Path & Downstream Handoff Sign-Off\n\n### Phase 3: Dataset Generation & Validation\n*Description:* Generate the YAML dataset and `2-networking.auto.tfvars` in the user's custom output directory, run `scripts/validate_dataset.py` (`terraform validate` and `terraform plan`), and write the dataset `README.md`.\\\n*Reference: [Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 4:** YAML Dataset & `2-networking.auto.tfvars` Generation\n- **Step 5:** Mechanical Validation (`scripts/validate_dataset.py`, including `terraform validate` and `terraform plan`)\n- **Step 6:** Dataset `README.md` & Follow-up Stage Handoff Summary\n","skills/fast/networking/references/1-requirements-to-topology.md":"# Phase 1 & 2 Reference: Requirements Discovery & Unified Topology/IPAM Proposal\n\nThis reference governs **Phase 1 (Requirements Discovery)** and **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Rather than asking the user to pick a FAST dataset name upfront—or dumping unrelated questions in static blocks—guide them through a **Conditional Decision DAG** and then present a **single unified Topology + Pre-Filled IPAM Proposal** for confirmation before generating YAML.\n\n---\n\n## 1. Silent Entry Detection (Never Ask a Workflow Meta-Question)\n\nInspect the user's first message and any attached files silently—**never** ask the user a multiple-choice question about how they want to start (interview vs. diagram vs. clone):\n\n- **If the user starts with a general design request (no diagram or detailed spec attached):** Start **Turn 1** of the Conditional Interview DAG (Section 2) immediately. You may include a brief one-line note in prose (*\"If you have an architecture diagram, IP table, or existing dataset you'd like to adapt, feel free to share it at any point\"*), then ask the Turn 1 baseline questions.\n- **If the user attaches or points to an architecture diagram (`.png`, `.jpg`, `.svg`, `.pdf`, `.drawio`) or an IP/subnet table / detailed prompt:**\n  1. Extract all stated facts (Projects, Single VPC vs. Hub & Spokes, regions, subnet CIDRs, interconnect/VPN links, NVAs, NCC hubs, peerings, DNS zones) and infer the required Stage 2 plumbing (e.g., GKE secondary ranges, `/23` `proxy_only: true` subnets for ALBs, `psa_configs` for Cloud SQL/AlloyDB, `$security_profile_groups:` for Cloud NGFW Enterprise).\n  2. Mark **Phase 1** completed and jump straight to **Phase 2 (Unified Topology, IPAM & Plumbing Proposal)** (Section 4), pre-filling sensible defaults for any unspecified CIDRs/ASNs and highlighting only the remaining ambiguities for confirmation.\n- **If the user asks to clone & mutate an existing in-tree dataset:** Jump straight to **Phase 2 (Section 4)** showing the proposed delta and IPAM table against `fast/stages/2-networking/datasets/<name>/`.\n- **If the user only asks to validate an existing dataset on disk:** Run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the results without running the interview.\n\n---\n\n## 2. Phase 1: Conditional Interview DAG (Step-by-Step Discovery)\n\nFollow this conditional flow so you never ask moot questions (such as asking about hubs and spokes when the user selected a Single Shared VPC):\n\n```text\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork                     │\n  │ (Ask together ONLY if not already stated in the user's initial prompt)     │\n  │  1. Stage 0 Integration (`0-org-setup` outputs vs. Standalone `.tfvars`)   │\n  │  2. Concrete Target Region(s) (`1..N` specific regions + shortname keys)   │\n  │  3. VPC Footprint Fork:                                                    │\n  │     • Path A: Single Shared VPC (1 VPC, microsegmented via FW/Tags/NGFW)   │\n  │     • Path B: Multi-VPC Hub & Spokes (Separate VPCs for IAM / quota scale) │\n  └───────────────────┬────────────────────────────────────┬───────────────────┘\n                      │                                    │\n         [Path A: Single Shared VPC]          [Path B: Multi-VPC Hub & Spokes]\n         (SKIP all hub/spoke/NCC/peering       ▼\n          and transit-NVA questions!)     ┌────────────────────────────────────┐\n                      │                   │ Turn 2B (Step 2a): Multi-VPC Scope │\n                      │                   │  1. Which spoke environments?      │\n                      │                   │  2. Inter-VPC reachability &       │\n                      │                   │     transit inspection model?      │\n                      │                   └────────────────┬───────────────────┘\n                      │                                    │\n                      └─────────────────┬──────────────────┘\n                                        ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 2A (if Path A) / Turn 3B (if Path B) (Step 2b):                       │\n  │ Connectivity, Security & Workload Plumbing (tailored to Path A or Path B)  │\n  │  1. Hybrid Connectivity (None / HA VPN / Cloud Interconnect / Both)        │\n  │  2. Internet Egress (Cloud NAT / Air-gapped [or Centralized via NVA])      │\n  │  3. Firewall Posture (Cloud-Native L4 Policies vs. Cloud NGFW Enterprise)  │\n  │  4. Workload Plumbing (None / GKE / L7 ALB `proxy_only` / PSA / PSC / etc) │\n  └────────────────────────────────────┬───────────────────────────────────────┘\n                                       ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Phase 2 (Step 3 — Single Unified Sign-Off Gate):                           │\n  │ Present Topology + Pre-Filled Regional IPAM/ASN/DNS Defaults + Output Path │\n  │ (User confirms defaults or tweaks CIDRs/names -> Proceed to Phase 3 Gen)   │\n  └────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork\nAsk only the following mutually independent questions (skipping any already answered in the user's prompt):\n\n1. **FAST `0-org-setup` Integration vs. Standalone Stage Execution:** Are you running `2-networking` on top of an existing FAST `0-org-setup` deployment, or standalone?\n   - *Why this matters:* `2-networking` can create new projects or reuse existing projects in either mode. Knowing if `0-org-setup` is used is strictly for **tfvars, provider, and context plumbing** (whether `0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` provide `$folder_ids`, `$storage_buckets`, `billing_account`, and `prefix`, or whether those variables must be supplied directly in `2-networking.auto.tfvars`).\n2. **Target Regions (`1..N`) & Location Keys:** Which specific GCP region(s) (`1` to `N`) will host your resources?\n   - *Important:* Present **concrete** single-region and multi-region choices (e.g., `europe-west1 + europe-west8`, `europe-west8 + europe-west12`, `europe-west8 (single region)`, `europe-west1 (single region)`, `us-central1 + us-east4`, or custom free-text) so you never need a second question to ask *which* region they picked.\n   - *Note on `defaults.yaml` location keys:* `primary` and `secondary` in the reference datasets are just arbitrary key names in `context.locations`. A dataset can define `1` to `N` regions using either role names (`primary`, `secondary`) or concise region shortnames (`ew1`, `ew8`, `ew12`, `usc1`). Propose clean shortname keys matching their chosen regions.\n3. **Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`):**\n   - *Framing to include in the question:* In GCP, network microsegmentation and L7 IPS (via Hierarchical/Network Firewall Policies, IAM-governed Secure Tags, and Cloud NGFW Enterprise) work **inside a single Shared VPC**—you do not need multiple VPCs for network security isolation. Multiple VPCs are used to **isolate IAM/administrative blast radii** across host projects (e.g., `dev` vs. `prod`) and to **scale horizontally** across VPC quota domains.\n   - **Option A — Single Shared VPC:** A single Shared VPC (`main-0` / `shared-0`) hosting all environments/subnets, segmented via subnet IAM, Firewall Policies, Secure Tags, and optional Cloud NGFW Enterprise. *(Ideal for PoCs, simple/compact estates, or low-latency enclaves—no hubs, spokes, NCC, or peering required).*\n   - **Option B — Multi-VPC Hub & Spokes:** Separate Shared VPCs per environment (`dev`, `prod`, etc.) interconnected through a central hub (via NCC, 3rd-party NVAs, or Peering).\n\n> **STOP after Turn 1 and wait for the user's answer before asking Turn 2 questions.**\n\n---\n\n### Turn 2 — Path A (`Single Shared VPC` Selected): Single-VPC Plumbing Discovery\nWhen the user selects **Option A (Single Shared VPC)** in Turn 1:\n- **Do NOT ask about spoke environments, spoke-to-spoke routing, NCC hubs, VPC peering, or inter-VPC transit NVAs.**\n- Ask only the Single-VPC questions below:\n  1. **Environment Subnets within the Single VPC:** Do you want a single general-purpose subnet per region, or separate subnets per logical environment (e.g., `dev` and `prod` subnets within the same Shared VPC for subnet-level IAM delegation)?\n  2. **Hybrid Connectivity:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)** terminating in this VPC? *(If Cloud Interconnect is selected, note that Phase 2 will confirm Dedicated vs. Partner mode, peer ASN, and interconnect/VLAN attachment parameters).*\n  3. **Internet Egress:** Should the VPC egress via **Cloud NAT** (`nat_config`), or have **no internet egress** (Air-gapped / Private Google Access only)?\n  4. **Firewall & Inspection Posture:** Are **Cloud-Native L4 Firewalls** (Hierarchical + VPC Firewall Policies) sufficient, or do you also want **Cloud NGFW Enterprise** (L7 IPS / TLS inspection rules via `$security_profile_groups`—or intra-VPC 3rd-party NVA hairpinning via Policy-Based Routing)?\n  5. **Workload & Managed Service Plumbing:** Which platforms will run in this VPC so Stage 2 can pre-provision their subnets/ranges? *(Include an explicit **\"None (standard VM subnets only)\"** option alongside **GKE Clusters** (`pods`/`services` secondary ranges), **Application Load Balancers** (`/23` `proxy_only: true` subnet), **Private Services Access / PSA** (`psa_configs` for Cloud SQL/AlloyDB/Memorystore/NetApp), **Private Service Connect / PSC** (`psc: true` NAT subnets or `psc:` consumer endpoints in `addresses.yaml`), and **Serverless / Vertex AI** (`/24` Direct VPC Egress / `network_attachments`))*.\n\n---\n\n### Turn 2 — Path B (`Multi-VPC Hub & Spokes` Selected): Inter-VPC & Inspection Discovery\nWhen the user selects **Option B (Multi-VPC Hub & Spokes)** in Turn 1, ask:\n\n1. **Spoke Environments:** Which workload environments need dedicated spoke Shared VPCs? *(Default: `dev` and `prod`; common additions: `nonprod`/`uat`, `dmz`)*.\n2. **Inter-VPC Reachability & Transit Inspection Pattern:** How should your spoke VPCs communicate with each other and with shared services/hybrid links?\n   - **Option A — Direct Spoke-to-Spoke over NCC (`hub-and-spokes-ncc` with `MESH`, Recommended Cloud-Native Default):** Full line-rate routing across spokes via NCC `MESH`, with microsegmentation enforced by Firewall Policies, Secure Tags, and/or **Cloud NGFW Enterprise**.\n   - **Option B — Central Hub Group + Isolated Edge Spokes over NCC (`hub-and-spokes-ncc` with `STAR`):** Shared-service/hub VPCs in the `center` group are reachable by all `edge` spokes (`dev`, `prod`), while `edge` spokes are isolated from each other at the routing table layer (strongly favored over traditional peering for scale and transitive hybrid/PSA/PSC routing).\n   - **Option C — Inspected via 3rd-Party Stateful NVA VMs (Palo Alto, Fortinet, Check Point, Linux/FRR):** Spoke-to-spoke and hybrid traffic is hairpinned through multi-NIC NVA VMs.\n     - **Always recommend `hub-and-spokes-nva-ncc` (NCC Router Appliance with dynamic BGP) for multi-regional architectures:** It is the **only** pattern that provides automatic cross-region failover via BGP/MED policies *and* allows managed services (such as PSA-peered Cloud SQL, AlloyDB, or NetApp) to use the best available regional route, since managed services do not support network-tagged static routes.\n     - Use **`hub-and-spokes-nva` (ILB Sandwich with static routes)** only for single-region deployments or specific point-to-point/DMZ `ILB-as-next-hop` legs where BGP is not used.\n   - **Option D — Strictly Isolated Spokes via VPC Peering (`hub-and-spokes-peerings`):** Small static estate (`<= 5` spokes, `$0` hub fee) where `dev` and `prod` peer only with `hub`.\n     - *Advisory warning:* Remind the user that **\"strict spoke isolation\" almost never holds true over time**—scenarios like exchanging anonymized data between `prod` and `dev`, or running shared CI/CD or AI agents that must operate across both environments, will bite them under non-transitive peering.\n\n### Turn 3 — Path B (`Multi-VPC Hub & Spokes`): Hybrid, Egress, Firewall & Workload Plumbing\nOnce the Multi-VPC pattern is selected in Turn 2B, ask the remaining plumbing questions:\n1. **On-Premises / Multicloud Links:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)**?\n   - *(Note: On NCC hybrid spokes, `site_to_site_data_transfer` defaults to `true` in `2-networking`; ask whether external sites need site-to-site transit through GCP (`true`, default) or only communicate with GCP VPCs (`false`, which also avoids errors in regions where S2S is unsupported). If Cloud Interconnect is selected, gather or propose Dedicated vs. Partner mode and `peer_asn`).*\n2. **Internet Egress Model:** Per-VPC **Cloud NAT** (`nat_config`), centralized egress through the NVA/DMZ VPC, or **no internet egress** (Air-gapped / PGA only)?\n3. **Cloud NGFW Enterprise (if Cloud-Native routing was selected in Turn 2B):** Standard L4 Firewall Policies (`goto_next`), or add **Cloud NGFW Enterprise** (`$security_profile_groups` L7 IPS rules)?\n4. **Workload & Managed Service Plumbing:** **None (standard subnets only)**, **GKE** (`pods`/`services` secondaries), **L7 ALB** (`/23` `proxy_only: true`), **PSA** (`psa_configs`), **PSC** (`psc: true` NAT subnets / `psc:` consumer endpoints in `addresses.yaml`), and/or **Serverless / Vertex AI**?\n\n---\n\n## 3. Mapping Requirements to `2-networking` Topologies\n\n```text\n  [Primary VPC Footprint Fork: Single VPC or Multi-VPC Hub & Spokes?]\n   ├── Single Shared VPC (PoCs, simple estates, or single IAM boundary)\n   │    └──> Custom Single-VPC Dataset (1 project + 1 VPC, no NCC/Peering/DNS-peering; derived from peerings dataset)\n   │\n   └── Multi-VPC Hub & Spokes (Separate host-project IAM boundaries or horizontal quota scale)\n        ├── [Do inter-VPC or hybrid flows require 3rd-party multi-NIC NVA VMs as the transit router?]\n        │    ├── Multi-region NVA (Recommended Default) ──> datasets/hub-and-spokes-nva-ncc (BGP Router Appliance)\n        │    ├── Single-region NVA / Static ILB only ─────> datasets/hub-and-spokes-nva (ILB Sandwich)\n        │    └── Hybrid (NCC-RA core + ILB DMZ leg) ──────> datasets/hub-and-spokes-nva-ncc (+ ilb_config on DMZ NIC)\n        │\n        └── [Cloud-native routing, with L4 Firewall Policies and/or Cloud NGFW Enterprise]\n             ├── Recommended Default (Any scale, hybrid/PSA/PSC transitivity) ──> datasets/hub-and-spokes-ncc\n             │    ├── Full spoke-to-spoke routing (microsegmented via FW/NGFW) ─> preset_topology: MESH\n             │    └── Routing-level isolation between edge spokes via center ───> preset_topology: STAR\n             │\n             ├── Small static estate (<= 5 spokes, $0 hub fee) ─────────────────> datasets/hub-and-spokes-peerings\n             │    (Warn user: strict isolation almost always bites later—e.g. exchanging anonymized prod/dev data or running agents across both envs)\n             │\n             └── Legacy / Niche (Not recommended for new designs; NCC wins) ────> datasets/hub-and-spokes-vpns\n```\n\n### Topology Comparison Matrix\n\n| Topology Pattern & In-Tree Baseline | Core Connectivity & VPC Layout | East-West Behavior | Scale & Quota Envelope | Best Fit & Recommendation Posture | Watch Out For... |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| **Single Shared VPC** *(Derived by simplifying `hub-and-spokes-peerings` to 1 project + 1 VPC)* | Single Shared VPC (`vpcs/main/` or `vpcs/shared/`) in 1 host project (`projects/net-core-0.yaml`). Zero `ncc-hubs/`, `nvas/`, or `peering_config`. | **Native intra-VPC routing** at full line rate; segmented via subnet IAM, Firewall Policies, Secure Tags, and/or Cloud NGFW Enterprise. | Up to **300 subnets** and **100–1,000 service projects** in a single quota domain. | **Ideal for PoCs, simple/compact estates, or low-latency enclaves** where a single network admin team manages the host project. | All environments share a single Shared VPC host project IAM boundary and VPC quota domain; intra-VPC 3rd-party appliance hairpinning requires Policy-Based Routing (`policy_based_routes`). |\n| **`hub-and-spokes-ncc`** *(Recommended Multi-VPC Cloud-Native Default)* | Central NCC Hub (`hub.yaml`) connecting `hub-0` (with hybrid VPN/VLAN spokes), `dev-0`, and `prod-0` as VPC spokes. | **Configurable:** Full any-to-any with `preset_topology: MESH`, or isolated edge spokes with `preset_topology: STAR` (`center` vs. `edge` groups). | Scales up to **250 VPC spokes** per hub (`4,000` subnet routes; `1,000` dynamic prefixes/region; `1,000` propagated PSC endpoints). | **Primary recommendation** for multi-VPC cloud-native and Cloud NGFW Enterprise estates; native transitive hybrid routing, PSC propagation (`export_psc: true`), and Producer VPC Spoke support. | `preset_topology` (`MESH` vs. `STAR`) is immutable after hub creation; `site_to_site_data_transfer` on hybrid spokes defaults to `true` (set `false` in regions that do not support S2S). |\n| **`hub-and-spokes-nva-ncc`** *(Recommended Multi-Region NVA Default)* | Dual NCC Hubs (`routing` `MESH` + `transit` `STAR`); multi-region NVAs (`nvas/primary.yaml`, `secondary.yaml`) attached as **NCC Router Appliance** spokes (`ncc_ra_config` + `bgp_config`). | **Inspected:** `dev-0` and `prod-0` attach to `transit` `edge` group; NVAs attach to `transit` `center` and `routing` `default`, inspecting all N-S and E-W flows over dynamic BGP. | Scales to NCC limits (**250 spokes**) with zero static routes and automatic intra-region / cross-region BGP failover. | **Always recommend for multi-region 3rd-party NVA architectures.** Only way to get automatic cross-region failover and let managed services (`PSA`) follow the best available route (since managed services do not support tagged static routes). | Never place workloads in the `transit` VPC (asymmetric return path); every prefix in `advertise` (control plane) must have a matching kernel route in `routes` (data plane). |\n| **`hub-and-spokes-nva`** | Multi-NIC NVA (`nvas/main.yaml`) with `ilb_config` directly attached via 4 NICs to `dmz`, `hub-0`, `dev-0`, and `prod-0` (no `peering_config` between VPCs). | **Inspected:** Static routes in each VPC steer inter-VPC and hybrid traffic to the NVA's per-VPC Internal Passthrough NLB VIP (`$addresses:<vpc>/main`). | Bounded by GCE maximum NICs per VM (`2` to `8` vNICs depending on machine shape, so at most 8 directly attached VPCs) and NVA VM bandwidth. | Single-region 3rd-party NVA deployments with $\\le 8$ VPCs, or specific `ILB-as-next-hop` DMZ/cross-environment legs where dynamic BGP is not used. | Static routes lack regional proximity metrics for multi-region failover; requires symmetric 5-tuple hashing (`PER_CONNECTION`, `session_affinity: NONE`, `NEVER_PERSIST`) and a shared health check. |\n| **`hub-and-spokes-peerings`** *(also symlinked as `classic`)* | `hub-0` peered via `peering_config` with `dev-0` and `prod-0` (`routes_config.export/import: true`). | **Isolated** (VPC Peering is strictly non-transitive). | Small estates (**$\\le 5$ spokes**). Shares peering-group quotas (`subnet_ranges_per_peering_group` = 1,000; `static_routes_per_peering_group` = 300). | Small, cost-sensitive setups (`$0` hub fee). **Caution:** Warn the user that \"strict spoke isolation\" is almost never true long-term—scenarios like exchanging anonymized data between `prod` and `dev` or running agents that work across both environments will bite them under non-transitive peering. | On-premises routes in `hub-0` cannot transit to PSA (`servicenetworking`) peered to `dev-0`/`prod-0`; Cloud DNS forwarding (`35.199.192.0/19`) cannot hop across peering to a VPN in another VPC. |\n| **`hub-and-spokes-vpns`** *(Legacy / Niche)* | `hub-0` connected to `dev-0` and `prod-0` via GCP-to-GCP HA VPN tunnels (`vpcs/<vpc>/vpns/*.yaml`) and Cloud Router BGP. | **Policy-Controlled:** Spokes exchange routes through `hub-0` Cloud Routers, filtered via CEL `route_policies` (`IMPORT`/`EXPORT`). | Up to 5 HA VPN gateways/region/project; bandwidth capped at **1.5–3 Gbps per IPsec tunnel** (`1460` byte MTU). | **Avoid for modern greenfield designs (NCC wins on throughput, cost, and simplicity).** Retain only for legacy brownfield compatibility or hard cross-org IPsec requirements. | IPsec tunnel throughput ceiling and MTU clamping (`1460` / `1406`); VPC firewall rules must allow `icmp` ingress so Path MTU Discovery (`ICMP Type 3 Code 4`) functions. |\n\n---\n\n## 4. Phase 2 (Step 3): Unified Topology, IPAM & Plumbing Proposal Template\n\nDo **not** split Topology Confirmation and IPAM Confirmation into two separate turns. As soon as Phase 1 requirements are gathered (or extracted upfront in Adaptive Mode), present a **single unified proposal** containing both the selected topology and pre-filled default CIDRs/ASNs/DNS domains, and ask the user once to confirm (or specify any custom CIDRs/names/output path) before generating the dataset in Phase 3:\n\n```markdown\n### Proposed FAST `2-networking` Topology & IPAM Blueprint\n\n- **Topology Pattern:** [`Single Shared VPC` | `datasets/<chosen-dataset>`]\n- **Target Output Directory:** `<proposed-or-user-specified-path>` *(e.g. `./fast-config/2-networking/data/`)*\n- **Stage 0 Integration:** [FAST `0-org-setup` (`$folder_ids:networking`) | Standalone `.tfvars`]\n- **Regions (`context.locations`):** `<key-1>` = `<region-1>` [, `<key-2>` = `<region-2>`, ...]\n\n#### 1. Proposed Projects, VPCs & Regional IPAM Allocation (Pre-Filled Defaults)\n| Project Key (`projects/`) | VPC Key (`vpcs/`) | Region (`$locations:`) | Subnet / Range Name | Proposed CIDR | Purpose & Stage 2 Plumbing |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| `net-core-0` | `main` (or `hub` / `routing`) | `<key-1>` | `<subnet-name>` | `10.64.0.0/24` | [Primary subnet / HA VPN / Cloud NAT / DNS] |\n| *(Additional rows for secondary regions, `proxy_only`, `psc`, `psa_configs`, GKE `pods`/`services`, and spoke VPCs as applicable)* | | | | | |\n\n#### 2. Routing, BGP, DNS & Firewall Summary\n- **East-West & Hybrid Routing:** [1–2 bullets including proposed Cloud Router / Peer BGP ASNs if VPN/Interconnect/NCC-RA is used]\n- **Cloud DNS & Firewall Posture:** [Proposed private/forwarding/peering DNS domains (`gcp.example.com.`, `onprem.example.com.`), RPZ, and Hierarchical/VPC Firewall rules]\n- **Downstream Stage Handoffs (Out of Scope for Stage 2):** [List any requested ALBs, Cloud NGFW Enterprise endpoints/profiles, GKE clusters, or Cloud SQL instances that will consume the Stage 2 plumbing in follow-up stages, or `None`]\n```\n\nAsk the user to confirm this unified blueprint (or tell you any CIDRs, ASNs, domain names, Interconnect details, or output path they want changed) before executing **Phase 3 (Dataset Generation & Validation)**.\n","skills/fast/networking/references/2-routing-and-security-mechanisms.md":"# Phase 2 Reference: Routing and Security Mechanisms Across Topologies\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Once the baseline topology is selected in `1-requirements-to-topology.md`, use this guide to explain and configure how North-South, East-West, Hybrid, Egress, DNS, and Inspection mechanisms work in YAML.\n\n---\n\n## 1. Cross-Topology Mechanism Matrix\n\n| Mechanism | `Single Shared VPC` *(Simple / PoC / Single-IAM)* | `hub-and-spokes-ncc` *(Multi-VPC Cloud-Native Default)* | `hub-and-spokes-nva-ncc` *(Multi-Region NVA Default)* | `hub-and-spokes-nva` *(Single-Region ILB Sandwich)* | `hub-and-spokes-peerings` *(Small Static Estate)* | `hub-and-spokes-vpns` *(Legacy / Niche)* |\n| :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n| **Spoke-to-Hub Routing** | N/A (single VPC `vpcs/main/`; no hub or peering needed). | `ncc_config` on hub & spokes (`hub: $ncc_hubs:hub`). | Dual NCC hubs (`routing` `MESH` + `transit` `STAR`); spokes attach to `transit` `edge` group. | Direct multi-NIC NVA attachment across `dmz`, `hub`, `dev`, `prod` + static routes to `$addresses:<vpc>/main` (no VPC peering). | Reciprocal `peering_config` on both hub & spoke (`routes_config.export/import: true`, enabled by default). | `vpcs/<vpc>/vpns/*.yaml` with reciprocal `$vpn_gateways:` + BGP. |\n| **East-West / Spoke Routing** | **Native intra-VPC routing**; isolated via Firewall Policies, Secure Tags, or Cloud NGFW. | **Direct** (`MESH` preset) or **Blocked at routing layer** (`STAR` `edge` group). | **Inspected via NVA:** Spokes in `transit/edge` route through NVAs in `transit/center` via BGP origination (`advertise` + `routes`). | **Inspected via NVA:** Spoke static routes point inter-spoke prefixes to the local spoke ILB VIP (`$addresses:<spoke>/main`). | **Blocked by design** (VPC Peering is strictly non-transitive). | Filtered via Cloud Router BGP `route_policies` (`IMPORT`/`EXPORT`) in `hub`. |\n| **On-Prem Hybrid Transit** | Terminates directly in `main` (`vpcs/main/vpns/` or `vlan-attachments/`); zero transitivity hops. | Terminates in `hub`; `ncc_spoke_config` on VPN/VLAN attaches tunnels/VLANs as NCC hybrid spokes. | Terminates in `routing` VPC on `routing` `MESH` hub; NVA inspects between `routing` and `transit` hubs. | Terminates in `dmz` or `hub` VPC; static routes steer traffic through NVA ILBs to spokes. | Terminates in `hub`; spokes import custom routes via peering (`routes_config.import: true`). | Terminates in `hub`; re-advertised to spokes over inter-VPC BGP. |\n| **Internet Egress** | `nat_config` (Cloud NAT) and PGA on `main`. | Per-VPC `nat_config` (Cloud NAT) and Private Google Access (`PGA`). | Per-VPC `nat_config` or centralized `0.0.0.0/0` originated on `routing` CR $\\rightarrow$ NVA (`masquerade_exclude`) $\\rightarrow$ Cloud NAT. | Per-VPC `nat_config` or centralized default route via NVA to `dmz`/`untrusted` NIC (`masquerade: true`) + Cloud NAT. | Per-VPC `nat_config` (Cloud NAT) and PGA. | Per-VPC `nat_config` or default route over hub VPN. |\n| **Firewall & Inspection** | Hierarchical policy (`goto_next`) + VPC rules + Secure Tags (+ optional Cloud NGFW or PBR). | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Stateful 3rd-party NVA inspection via BGP RA spokes (active-passive MED or active-active ECMP) + VPC/Hierarchical rules. | Stateful 3rd-party NVA inspection via ILB next-hop (`PER_CONNECTION`, `NONE` affinity, `NEVER_PERSIST`) + VPC/Hierarchical rules. | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Hierarchical policy + VPC firewall rules + BGP prefix filtering. |\n| **Cloud DNS Pattern** | All `private` and `forwarding` zones bind directly to `client_networks: [$networks:main]` (no DNS peering needed). | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub` + RPZ. | Per-spoke private zones bound to spoke + `routing`; `routing` hosts on-prem forwarding; `transit` & spokes peer `.` to `routing` + RPZ. | Per-spoke private zones bound to spoke + `hub`; `dmz` & spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; spokes peer `.` to `hub`. |\n\n---\n\n## 2. Cloud-Native Routing Mechanics (`single-vpc`, `ncc` & `peerings`)\n\n### 2.0 Single Shared VPC (`single-vpc`)\n- **Dataset Structure:** Derived by simplifying `hub-and-spokes-peerings` to a single host project (`projects/net-core-0.yaml` with `shared_vpc_host_config.enabled: true`) and a single VPC (`vpcs/main/.config.yaml`). Omit `ncc-hubs/`, `nvas/`, `peering_config`, and `ncc_config`.\n- **Environment Segmentation without Multiple VPCs:**\n  - Create either general-purpose regional subnets or per-environment regional subnets (e.g., `vpcs/main/subnets/dev-ew8.yaml`, `prod-ew8.yaml`) inside `vpcs/main/subnets/`, delegating subnet-level `roles/compute.networkUser` IAM to downstream service projects.\n  - Enforce East-West isolation between environments or tiers using Hierarchical/VPC Firewall Policies, IAM-governed Secure Tags (`$tag_values:`), and optional Cloud NGFW Enterprise (`$security_profile_groups:`).\n- **Direct Hybrid, PSA & DNS Simplicity:**\n  - HA VPNs (`vpcs/main/vpns/`), VLAN attachments (`vpcs/main/vlan-attachments/`), and Private Services Access (`psa_configs`) all attach directly to `main`, eliminating inter-VPC transitivity constraints.\n  - All Cloud DNS `private` and `forwarding` zones in `dns/zones/` bind directly to `client_networks: [$networks:main]`, and `dns/response-policies/*.yaml` lists only `networks: [$networks:main]`—do **not** create a `peer-root.yaml` DNS peering zone in a Single VPC dataset.\n\n### 2.1 Network Connectivity Center (`hub-and-spokes-ncc`)\n- **`MESH` vs. `STAR` Preset Topology (`ncc-hubs/<hub>.yaml`):**\n  - `preset_topology: MESH` (default) places all spokes in `groups.default`, enabling direct line-rate routing between every spoke (microsegmented via Firewall Policies, Secure Tags, or Cloud NGFW Enterprise).\n  - `preset_topology: STAR` creates two groups (`center` and `edge`). Attach shared-service/transit VPCs to `$ncc_groups:<hub>/center` and workload environment VPCs (`dev`, `prod`) to `$ncc_groups:<hub>/edge` when you want routing-table isolation between workload spokes while sharing central services.\n  - *Constraint:* `preset_topology` is immutable after hub creation.\n- **PSC Propagation:** `export_psc` defaults to `true` in `factory-ncc.tf` so Private Service Connect consumer endpoints created in one spoke (such as `hub`) are automatically reachable from all connected VPC spokes.\n- **NCC Hybrid Spokes (`vpns/*.yaml` & `vlan-attachments/*.yaml`):**\n  - Adding `ncc_spoke_config: { hub: $ncc_hubs:hub }` to a VPN or VLAN attachment file registers it as an NCC hybrid spoke (`factory-ncc.tf` automatically configures `include_import_ranges = [\"ALL_IPV4_RANGES\"]` so Cloud Routers import dynamic routes from the hub and advertise spoke subnets back to on-premises peers).\n  - **`site_to_site_data_transfer` (`true` default vs. `false`):** In `factory-ncc.tf`, `vpn.schema.json`, and `vlan-attachments.schema.json`, `site_to_site_data_transfer` **defaults to `true`**, allowing two hybrid spokes attached to the hub (for example, an On-Premises VPN/Interconnect and a colocated Partner Interconnect) to exchange routes and traffic directly through Google Cloud. Explicitly set `site_to_site_data_transfer: false` when external sites only communicate with GCP VPCs or when deploying in a GCP region that does not support NCC site-to-site data transfer.\n\n### 2.2 VPC Network Peering (`hub-and-spokes-peerings`)\n- **Reciprocal Declaration:** Peering must be declared in **both** VPCs' `.config.yaml` files (`hub` $\\rightarrow$ `prod` and `prod` $\\rightarrow$ `hub`).\n- **Custom Route Exchange:** `factory-peering.tf` defaults `routes_config.export` and `routes_config.import` to `true` so spokes automatically import on-premises prefixes learned by `hub` over VPN/Interconnect.\n- **Non-Transitivity Traps:**\n  1. Workloads in `dev` cannot reach `prod` over `hub` (and if they later need to exchange anonymized data or run shared agents across both environments, peering cannot route it without an intermediate proxy/NVA or migrating to NCC).\n  2. On-premises networks terminating in `hub` cannot reach Private Services Access (`psa_configs` / `servicenetworking`) peered to `dev` or `prod`.\n  3. Cloud DNS outbound forwarding packets (`35.199.192.0/19`) cannot traverse a VPC peering link to reach a VPN/Interconnect in another VPC—always host the forwarding zone in the VPC that terminates the hybrid link (and use DNS peering from spokes).\n\n---\n\n## 3. 3rd-Party NVA Routing & Failover Mechanics (`nva-ncc` vs. `nva`)\n\n### 3.1 Multi-Region NVA via NCC Router Appliance (`hub-and-spokes-nva-ncc`)\nWhenever 3rd-party stateful NVA VMs are used in a **multi-regional architecture** (or alongside PSA managed services), recommend `hub-and-spokes-nva-ncc`:\n\n```text\n  [On-Prem VPN/VLAN] ──> ( routing VPC ) <──BGP── [ NVA nic0 ]\n                              │                       │ (Stateful Inspection)\n                        (routing MESH hub)            ▼\n                                                [ NVA nic1 ] ──BGP──> ( transit VPC )\n                                                                            │\n                                                                 (transit STAR hub: center)\n                                                                            │\n                                                                 (transit STAR hub: edge)\n                                                                      ├── ( dev VPC )\n                                                                      └── ( prod VPC )\n```\n\n1. **Dual-Hub Architecture:**\n   - **`routing` Hub (`MESH`):** Connects hybrid spokes (HA VPN / VLAN attachments in the `routing` VPC) and `nic0` of each regional NVA as a Router Appliance spoke.\n   - **`transit` Hub (`STAR`):** Connects `nic1` of each regional NVA to the `center` group (`$ncc_groups:transit/center`) and workload spoke VPCs (`dev`, `prod`) to the `edge` group (`$ncc_groups:transit/edge`).\n   - *Critical Rule:* **Never host workloads in the `transit` VPC.** The `transit` VPC is the routing VPC for the `transit` RA spokes and learns spoke subnet routes directly from the hub; a VM inside `transit` would send traffic to a spoke via the NVA, while return traffic from the spoke would bypass the NVA and go directly to the `transit` subnet (asymmetric drop).\n2. **`routes` (Data Plane) vs. `advertise` (Control Plane) in `nvas/*.yaml`:**\n   - `routes` installs static Linux kernel routes inside the NVA guest OS via that NIC's subnet gateway so the OS knows which interface forwards to a prefix.\n   - `advertise` originates prefixes in FRR via BGP `network` statements toward Cloud Router peers (used on `nic1` in `transit` so edge spokes—which do not exchange subnet routes with each other in a `STAR` hub—learn routes to other spokes through the NVA).\n   - *Invariant:* Because FRR prefers its own locally originated `network` statement (`weight 32768`) over eBGP routes learned from Cloud Router (`weight 0`), **every prefix listed in `advertise` must be covered by a kernel route in `routes`** (or the default route on a `masquerade` NIC), otherwise the NVA kernel will lack a forwarding entry and blackhole the traffic.\n3. **Active-Passive (No Session Sync) vs. Active-Active ECMP:**\n   - **Active-Passive + Regional Affinity (Default in `hub-and-spokes-nva-ncc`):** Configure explicit per-instance BGP peers on the Cloud Routers using `$nva_addresses:<nva>-<zone>-<index>/<vpc>` and `$nva_instances:<nva>-<zone>-<index>`, attaching distinct `IMPORT` route policies (`med.set(100)` for primary zone `b`, `med.set(150)` for standby zone `c`, and `med.set(10100)` / `10150` for remote-region prefixes). This guarantees symmetric forward and return paths through the same regional primary NVA without requiring cross-instance state synchronization, while failing over automatically within the region or across regions if an appliance or region fails.\n   - **Active-Active ECMP:** Configure a fan-out peer (`nva: <nva-key>`) on the Cloud Router interfaces sharing a single import policy. Both zonal instances advertise at equal MED; use only when the NVA performs stateless inspection or runs active-active state synchronization (e.g., FGSP) and SNAT is excluded for private ranges (`masquerade_exclude`).\n4. **Why `NCC-RA` Wins for Managed Services (`PSA`):**\n   - Managed service producer networks (`servicenetworking` for Cloud SQL, AlloyDB, Memorystore, NetApp) do not have GCE network tags and cannot use network-tagged static routes. Because `NCC-RA` injects dynamic BGP routes into the VPC route table with regional MED priorities, PSA-peered services automatically use the local region's healthy NVA and fail over to the secondary region dynamically.\n\n### 3.2 Single-Region ILB Sandwich (`hub-and-spokes-nva`) & Hybrid `NCC-RA` + `ILB` Chaining\n- **When to use `ilb_config`:** Single-region NVA deployments (`hub-and-spokes-nva`, where `dmz`, `hub`, `dev`, and `prod` are all attached directly to the NVA's 4 NICs without VPC Peering), or when attaching a 3rd NIC (`nic2`) of an `NCC-RA` NVA to a dedicated DMZ or cross-environment bridge VPC using Internal Passthrough NLB next-hop static routes (`$addresses:<vpc>/<nva-name>`).\n- **Mandatory Conntrack & Health Check Guardrails:**\n  - The stage's `factory-nva.tf` configures a single shared health check and defaults `ilb_config.connection_tracking` to `track_per_session: false` (`PER_CONNECTION` 5-tuple tracking) and `persist_conn_on_unhealthy: NEVER_PERSIST` (with `session_affinity: NONE` and 600s idle timeout). Never override `ilb_config.connection_tracking` with `track_per_session: true` (`PER_SESSION` 2-tuple hashing causes asymmetric forward/return selection across NVA instances during health flaps).\n- **Intra-VPC Hairpinning with Policy-Based Routing (PBR):**\n  - If traffic *within* a single VPC (or entering a VPC from a specific source CIDR) must be forced through an NVA ILB, configure `policy_based_routes` in `vpcs/<vpc>/.config.yaml` pointing `next_hop_ilb_ip` to `$addresses:<vpc>/<nva-name>`.\n  - *PBR Guardrail:* Always include a higher-priority (lower numeric `priority` value) rule with `use_default_routing: true` matching Private Service Connect / Google API ranges first so wildcard PBR (`0.0.0.0/0`) does not hijack local PSC/PGA traffic into the NVA.\n\n---\n\n## 4. Firewall Policies & Cloud NGFW Enterprise\n\n1. **Hierarchical & Network Firewall Policies (`firewall-policies/*.yaml`):**\n   - Both Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>` or `organizations/<id>`) and Network Firewall Policies (`parent_id: $project_ids:<key>` with `region: global` or `$locations:<key>`) are defined in `firewall-policies/*.yaml` with an `attachments:` map inside the policy YAML file.\n   - Rules in `modules/net-firewall-policy` default to `action: allow` (ingress) or `action: deny` (egress) if `action` is omitted; always set `action: goto_next` explicitly on rules that should delegate evaluation to lower-level policies or VPC rules.\n   - Supports symbolic CIDR sets (`$cidr_ranges_sets:healthchecks`, `$cidr_ranges_sets:rfc1918`) defined in `defaults.yaml`.\n2. **VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n   - **Rule Key Uniqueness per Project:** GCE firewall rule names are project-scoped. If two VPCs share the same `project_id` (for example, `routing` and `transit` both living in `$project_ids:net-core-0`), their firewall rule keys across `vpcs/routing/firewall-rules/` and `vpcs/transit/firewall-rules/` **must be distinct** (e.g., `allow-routing-ssh` vs. `allow-transit-ssh`) to avoid HTTP 409 conflicts.\n   - **ICMP for PMTUD on VPN VPCs:** Any VPC terminating or transiting HA VPN tunnels must allow `icmp` ingress (especially `ICMP Type 3 Code 4` Fragmentation Needed) so Path MTU Discovery adjusts TCP MSS for in-flight connections across the `1406`/`1460`-byte IPsec tunnel MTU.\n3. **Cloud NGFW Enterprise Integration (L7 IPS / TLS Inspection):**\n   - In `firewall-policies/*.yaml`, rules can set `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>` (where `var.security_profile_groups` is passed from a `2-networking-ngfw` or security stage).\n\n---\n\n## 5. Cloud DNS & Name Resolution Mechanics\n\n1. **Private & Hybrid DNS Topologies (`dns/zones/`):**\n   There are three main patterns for structuring private and forwarding zones across hub and spoke VPCs:\n   - **Pattern A — Per-Environment Zones Bound to Hub + Root Peering (Recommended Default, used in the FAST reference datasets):**\n     1. Each environment manages its own private zone in its own spoke project (e.g., `dns/zones/net-dev-0/pvt-dev.yaml` with `project_id: $project_ids:net-dev-0`) and uses **cross-project VPC binding** (`private.client_networks: [$networks:hub, $networks:dev]`) to bind the zone to both the spoke VPC and the centralized hub VPC (`hub` or `routing`).\n     2. The centralized hub VPC—which **must** be the VPC where hybrid VPN/Interconnect connectivity terminates so Cloud DNS forwarding packets (`35.199.192.0/19`) have a direct path to on-premises resolvers—hosts the on-premises forwarding zone (`forwarding.client_networks: [$networks:hub]`) and top-level shared private zones.\n     3. All spoke VPCs attach to a root peering zone (`domain: .`, `peering.peer_network: $networks:hub`, `client_networks: [$networks:dev, $networks:prod]`)—or, alternatively, bind the forwarding zone directly across VPCs when routing allows. Root peering to the centralized VPC is **preferred** because spokes automatically resolve on-premises domains and all environment zones bound to the hub.\n   - **Pattern B — Fully Centralized Zones + Root Peering:**\n     - All private zones and on-premises forwarding zones are defined centrally in `net-core-0` and bound to the centralized hub VPC (`$networks:hub` or `$networks:routing`), while every spoke VPC peers root (`domain: .`) to the centralized VPC.\n   - **Pattern C — Cross-Project Bindings Only (`client_networks`):**\n     - Private zones (defined either centrally in `net-core-0` or per spoke project) are bound directly to all consumer VPCs via `private.client_networks: [$networks:hub, $networks:dev, $networks:prod]` (requires cross-project IAM permissions, which Stage 2 manages when all projects are in the dataset).\n2. **Cloud DNS Response Policies (`dns/response-policies/*.yaml`):**\n   - Used to steer Google APIs (`*.googleapis.com.`, `*.gcr.io.`, `*.pkg.dev.`) to `private.googleapis.com.` (`199.36.153.8/30`), `restricted.googleapis.com.` (`199.36.153.4/30`), or a custom PSC Google API endpoint IP.\n   - **Regional Endpoint (`*.rep.googleapis.com.`) Bypass:** Cloud DNS Response Policies match by **most-specific DNS suffix** (there is no `priority` attribute in `dns-response-policy-rules.schema.json`). When workloads use GCP Regional Endpoints (`<service>.<region>.rep.googleapis.com.`), include a rule for `dns_name: \"*.rep.googleapis.com.\"` with `behavior: bypassResponsePolicy`—because `*.rep.googleapis.com.` is more specific than `*.googleapis.com.`, it automatically bypasses the wildcard CNAME override so regional TLS certificate validation succeeds.\n   - **PSC Interfaces (`PSC-I`) & Root / `googleapis.com.` Peering:** If a spoke VPC uses PSC Interfaces (`network_attachments` for Vertex AI Agent Engine with VPC-scoped DNS), note that DNS Peering takes precedence over local private zones; avoid peering `googleapis.com.` (or use Pattern C cross-project bindings + RPZ on that spoke) so local PSC-I resolution is not overridden.\n","skills/fast/networking/references/3-stage-boundaries-and-plumbing.md":"# Phase 2 Reference: Stage 2 Boundaries & Downstream Plumbing Contract\n\n`fast/stages/2-networking` is a **foundational network plumbing stage**. It creates the projects, VPCs, subnets, IP reservations, routers, interconnects/VPNs, NCC hubs/spokes, base NVAs, DNS zones, and firewall policies that downstream stages consume.\n\nWhen a user states a requirement for a security capability, load balancer, container platform, or managed database, **never** invent unsupported YAML keys inside `2-networking` and **never** tell the user FAST cannot support it. Instead:\n1. Configure the **Stage 2 network plumbing** required by that service in the YAML dataset.\n2. Explain what is delegated to the **follow-up stage** (`1-vpcsc`, `2-networking-ngfw` / security stage, `2-project-factory`, or workload stages).\n3. Document every delegated item in the generated dataset's `README.md` under a **Downstream Stage Handoffs** section.\n\n---\n\n## 1. Stage Boundary Principle & Representative Plumbing Matrix\n\n**General Boundary Principle:** Any GCP service or security capability whose data-plane or control-plane depends on shared VPC primitives (subnets, secondary ranges, special-purpose subnets, IP reservations, PSA peerings, PSC network attachments, routes, DNS zones/RPZ, or firewall policy rules) has those **foundational network primitives provisioned in `2-networking`** (per `fast/stages/2-networking/schemas/*.schema.json`), while the **service, endpoint, or workload resource itself is provisioned in a follow-up stage**.\n\nThe table below shows representative examples of how common capabilities split across `2-networking` and downstream stages:\n\n| Requested Capability | What `2-networking` Provisions (Stage 2 YAML Plumbing) | What is Delegated to Other Stages (Out of Scope for `2-networking`) |\n| :--- | :--- | :--- |\n| **Cloud NGFW Enterprise** *(L7 IPS, Threat Prevention, TLS Inspection)* | • Hierarchical or network firewall policy rules in `firewall-policies/*.yaml` setting `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>`.<br/>• Context mapping via `var.security_profile_groups` in `.tfvars`. | • `google_network_security_firewall_endpoint`, `firewall_endpoint_association`, and `security_profile_group` resources (provisioned in a dedicated **`2-networking-ngfw` / security stage**). |\n| **Application Load Balancers** *(Internal L7 ALB, Regional External ALB, Service Extensions)* | • Regional proxy-only subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `proxy_only: true`, `/23` recommended, `/26` GCP minimum per region/VPC).<br/>• Static VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal`, `external`, or `global`).<br/>• Firewall rules allowing health-check probers (`$cidr_ranges_sets:healthchecks`: `35.191.0.0/16`, `130.211.0.0/22`) and proxy subnet CIDRs. | • Load balancer forwarding rules, target HTTP(S) proxies, URL maps, backend services, NEGs, and Cloud Armor policies (`modules/net-lb-app-*`, deployed in **workload stages**). |\n| **Internal Passthrough NLBs** *(Application-Tier L4 ILBs)* | • Workload subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`).<br/>• Static internal VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal:`).<br/>• Health-check allow rules (`35.191.0.0/16`, `130.211.0.0/22`). | • Application-tier `modules/net-lb-int` resources (deployed in **workload stages**). *(Note: NVA transit ILBs ARE managed directly by `2-networking` via `nvas/*.yaml` `ilb_config`).* |\n| **GKE Clusters** *(Standard & Autopilot)* | • Shared VPC host enablement (`shared_vpc_host_config.enabled: true`) and `container.googleapis.com` API in `projects/net-*.yaml`.<br/>• Primary node subnets with `secondary_ip_ranges` (`pods` and `services`) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• Optional firewall rules for private control-plane webhooks/master CIDRs. | • Service project creation and Shared VPC subnet-level IAM attachment (`roles/compute.networkUser`, `roles/container.hostServiceAgentUser` in **`2-project-factory`**).<br/>• GKE cluster & nodepool provisioning (`modules/gke-*` in **workload stages**). |\n| **Private Service Connect (PSC) — Producer** *(Publishing Services Across VPCs/Orgs)* | • Dedicated PSC NAT subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `psc: true`, `/24` recommended per service attachment).<br/>• Regional `proxy_only: true` subnet if the published producer LB is an Internal Regional ALB. | • Producer load balancer and `google_compute_service_attachment` (`modules/net-lb-*` in **producer workload stages**). |\n| **Private Service Connect (PSC) — Consumer** *(Consuming SaaS, Managed DBs, or Google APIs)* | • Consumer PSC endpoint IPs in `vpcs/<vpc>/addresses.yaml` under `psc:` (`subnet_self_link` + `service_attachment: { psc_service_attachment_link: ... }` for service attachments, or `global: true` + `target: all-apis\\|vpc-sc` for Google API bundles).<br/>• Private Cloud DNS zones/records in `dns/zones/` or RPZ rules in `dns/response-policies/`.<br/>• `export_psc: true` on `ncc-hubs/*.yaml` to propagate consumer endpoints across NCC spokes. | • Producer-side project allowlisting (`consumer_accept_lists`) if the producer is external. |\n| **Private Services Access (PSA)** *(Cloud SQL, AlloyDB, Memorystore, NetApp Volumes)* | • `servicenetworking.googleapis.com` API in `projects/net-*.yaml`.<br/>• `psa_configs` in `vpcs/<vpc>/.config.yaml` (allocating `/24`–`/16` internal CIDR ranges, `export_routes: true`, `import_routes: true`).<br/>• Cloud Router `custom_advertise.ip_ranges` if on-premises networks need to reach the PSA ranges.<br/>• Note: If multiple NCC spokes need transitive access to a PSA peering (`servicenetworking-googleapis-com` or `sn-netapp-prod`), document attaching an **NCC Producer VPC Spoke** (`linked_producer_vpc_network`) or using PSC endpoints instead. | • Managed database or storage instances (`modules/cloudsql-*`, `alloydb`, `netapp-volumes` in **workload stages**). |\n| **Serverless & Vertex AI Runtimes** *(Cloud Run Direct VPC Egress, Vertex AI Agent Engine PSC-I)* | • Dedicated subnets (`>= /26`, `/24` recommended to account for `2x` rollout IP reservation) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• `network_attachments` in `vpcs/<vpc>/.config.yaml` for PSC Interfaces (`PSC-I`, referencing `<resolved-region>/<subnet-name>` and `automatic_connection`).<br/>• Cloud DNS RPZ bypass (`*.rep.googleapis.com.` `bypassResponsePolicy`) and ensuring `googleapis.com.` is **not** in `dns/zones/` as a DNS Peering zone on VPCs using `PSC-I`. | • Service project attachment in **`2-project-factory`**.<br/>• Cloud Run services, Vertex AI endpoints, and Agent Engine deployments in **workload stages**. |\n| **VPC Service Controls (VPC-SC)** *(Data Exfiltration Perimeters)* | • Plumbing for `restricted.googleapis.com` (`199.36.153.4/30`) routes and DNS response policies (`dns/response-policies/*.yaml`).<br/>• Optional `$vpc_sc_perimeters:` mapping on networking host projects (`var.perimeters`). | • Access Context Manager policies, access levels, and regular/bridge service perimeters (managed in **`1-vpcsc`**). |\n\n---\n\n## 2. Example YAML Plumbing Snippets for `2-networking`\n\nThe snippets below are **illustrative examples** showing how Stage 2 plumbing is expressed against the `fast/stages/2-networking/schemas/*.schema.json` schemas. Adapt names, keys, regions, and CIDRs to the user's design, and consult the JSON schemas in `fast/stages/2-networking/schemas/` for the full set of supported attributes. *(Note: when grouping subnets in regional folders under `vpcs/<vpc>/subnets/<region>/`, ensure every `.yaml` file within a VPC has a **unique filename stem**, e.g. `prod-proxy-ew1.yaml` and `prod-proxy-ew8.yaml`, because `modules/net-vpc` keys raw subnet files by basename).*\n\n### 2.1 Regional ALB Proxy-Only & PSC Producer Subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-proxy-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-proxy-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.128.0/23\ndescription: Regional Managed Proxy subnet for Internal and Regional External ALBs\nproxy_only: true\nactive: true\n```\n\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-psc-nat-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-psc-nat-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.130.0/24\ndescription: PSC NAT subnet for publishing Service Attachments\npsc: true\n```\n\n### 2.2 GKE Node Subnet with Pod & Service Secondaries (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-gke-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-gke-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.0.0/22\ndescription: Primary subnet for GKE nodes with secondary Pod and Service ranges\nsecondary_ip_ranges:\n  pods:\n    ip_cidr_range: 10.104.0.0/17\n  services:\n    ip_cidr_range: 10.108.0.0/21\n```\n\n### 2.3 PSA Ranges & PSC Interfaces (`network_attachments`) in `vpcs/<vpc>/.config.yaml`\n```yaml\n# Inside vpcs/prod/.config.yaml\npsa_configs:\n  - ranges:\n      cloudsql-psa: 10.110.0.0/20\n    export_routes: true\n    import_routes: true\n\nnetwork_attachments:\n  agent-engine-ew1:\n    # Must be '<resolved-region>/<subnet-name>' matching a subnet in this VPC:\n    subnet: europe-west1/prod-psc-int-ew1\n    automatic_connection: false\n    # Literal GCP project IDs (producer_accept_lists does not interpolate $project_ids):\n    producer_accept_lists:\n      - my-ai-workload-prod-0\n```\n\n### 2.4 Reserved VIPs & PSC Consumer Endpoints (`vpcs/<vpc>/addresses.yaml`)\n```yaml\n# vpcs/prod/addresses.yaml\n# yaml-language-server: $schema=../../../schemas/addresses.schema.json\n\ninternal:\n  # Referenced in routes/PBR as $addresses:prod/app-ilb-ew1\n  app-ilb-ew1:\n    region: $locations:ew1\n    subnetwork: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.10\n\npsc:\n  alloydb-psc-ew1:\n    region: $locations:ew1\n    subnet_self_link: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.50\n    service_attachment:\n      psc_service_attachment_link: projects/producer-prj/regions/europe-west1/serviceAttachments/alloydb-sa\n```\n\n### 2.5 Cloud NGFW Enterprise Rule in `firewall-policies/*.yaml`\n```yaml\n# Inside firewall-policies/networking-policy.yaml\ningress_rules:\n  inspect-east-west:\n    priority: 1000\n    action: apply_security_profile_group\n    security_profile_group: $security_profile_groups:ips-profile-group\n    description: Delegate L7 IPS inspection to Cloud NGFW Enterprise\n    match:\n      source_ranges:\n        - $cidr_ranges_sets:rfc1918\n      destination_ranges:\n        - $cidr_ranges_sets:rfc1918\n      layer4_configs:\n        - protocol: all\n```\n\n---\n\n## 3. Handoff Outputs Contract for Downstream Stages\n\nWhen `2-networking` runs `terraform apply`, it emits stage outputs (`host_project_ids`, `vpc_self_links`, `subnet_self_links`, `subnet_proxy_only_self_links`, `subnet_psc_self_links`, and `2-networking.auto.tfvars.json`) consumed by `2-project-factory` and workload stages.\n\nAlways include a **Downstream Stage Handoffs** table in the generated dataset's `README.md` listing:\n1. Which **Shared VPC Host Projects** (`$project_ids:<key>`) and **Subnets** (`<vpc>/<region>/<subnet>`) downstream service projects should attach to in `2-project-factory`.\n2. Which **`proxy_only`**, **`psc`**, **`psa_configs`**, or **`network_attachments`** resources were pre-created in `2-networking` and which workload stage / CFF module (`gke-cluster-*`, `net-lb-app-*`, `cloudsql-*`) will consume them.\n3. Any external context variables (`var.security_profile_groups`, `var.folder_ids`, `var.kms_keys`, `var.perimeters`) that must be passed into `2-networking.auto.tfvars` when running `terraform apply`.\n","skills/fast/networking/references/4-dataset-schema-and-tokens.md":"# Phase 2 & 3 Reference: Dataset Directory Layout, Schemas & `$token` Rules\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)** and **Phase 3 (Dataset Generation & Validation)**. Consult the live JSON schemas in `fast/stages/2-networking/schemas/*.schema.json` alongside this guide when generating or modifying YAML files.\n\n---\n\n## 1. Canonical Dataset Directory Hierarchy & Schema Mapping\n\nEvery YAML file in a `2-networking` dataset must sit at one of the canonical factory paths below and begin with a `# yaml-language-server: $schema=...` header pointing to the corresponding schema in `fast/stages/2-networking/schemas/`:\n\n| Dataset-Relative Path | JSON Schema (`fast/stages/2-networking/schemas/`) | Factory & Purpose |\n| :--- | :--- | :--- |\n| `defaults.yaml` | `defaults.schema.json` | Stage defaults (`vpcs.defaults`, `projects.defaults`), `output_files`, and `context` maps (`locations`, `cidr_ranges`, `cidr_ranges_sets`, `email_addresses`, `iam_principals`, etc.). |\n| `folders/**/.config.yaml` | `folder.schema.json` | Optional folder hierarchy definitions (up to 4 levels) under the networking branch. |\n| `projects/**/*.yaml` or `folders/**/*.yaml` *(except `.config.yaml`)* | `project.schema.json` | Networking host/transit project definitions (`name`, `parent`, `services`, `shared_vpc_host_config`, `project_reuse`). Projects placed under `folders/<dir>/<proj>.yaml` automatically inherit `parent: $folder_ids:<dir>`. |\n| `ncc-hubs/*.yaml` | `ncc-hub.schema.json` | NCC Hub (`name`, `project_id`, `preset_topology: MESH\\|STAR`, `export_psc`, `groups`). |\n| `nvas/*.yaml` | `nva.schema.json` | Multi-NIC NVA clusters (`auto_instance_config.nics`, `instance_groups`, `ilb_config`, `ncc_ra_config`, `bgp_config`). |\n| `firewall-policies/**/*.yaml` | `firewall-policy.schema.json` | Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>`) and Global/Regional Network Firewall Policies (`parent_id: $project_ids:<key>`, `region: global` or `$locations:<key>`, `attachments: { <vpc>: $networks:<vpc> }`). |\n| `dns/zones/**/*.yaml` | `dns.schema.json` | Cloud DNS zones (`private`, `forwarding`, `peering`, `public`, `recordsets`, `delegations`). |\n| `dns/response-policies/**/*.yaml` | `dns-response-policy-rules.schema.json` | Cloud DNS Response Policy zones and rules (`local_data`, `behavior: bypassResponsePolicy`). |\n| `vpcs/<vpc-key>/.config.yaml` | `vpc.schema.json` | VPC configuration (`name`, `project_id`, `mtu`, `routing_mode`, `nat_config`, `ncc_config`, `peering_config`, `psa_configs`, `network_attachments`, `routers`, `routes`, `policy_based_routes`, `dns_policy`, `factories_config`). |\n| `vpcs/<vpc-key>/addresses.yaml` | `addresses.schema.json` | Reserved addresses in `<vpc-key>` (`internal`, `external`, `global`, `psc`, `psa`). |\n| `vpcs/<vpc-key>/firewall-rules/**/*.yaml` | `firewall-rules.schema.json` | VPC-level `ingress` and `egress` firewall rules. |\n| `vpcs/<vpc-key>/subnets/**/*.yaml` | `subnet.schema.json` | Subnet definitions (`name`, `region`, `ip_cidr_range`, `secondary_ip_ranges`, `proxy_only`, `psc`, `private_nat`, `iam`). |\n| `vpcs/<vpc-key>/vpns/**/*.yaml` | `vpn.schema.json` | HA VPN gateways (`name`, `region`, `peer_gateways`, `router_config`, `tunnels`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/vlan-attachments/**/*.yaml` | `vlan-attachments.schema.json` | Dedicated/Partner Interconnect VLAN attachments (`name`, `region`, `peer_asn`, `router_config`, `dedicated_config` / `partner_config`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/attachment-groups/**/*.yaml` | `attachment-groups.schema.json` | Interconnect attachment groups for SLA redundancy. |\n\n---\n\n## 2. Symbolic Context (`$token`) Namespace & Key Derivation Rules\n\nFAST `2-networking` resolves `$namespace:key` tokens across factories. Never hardcode GCP resource self-links when a context token exists.\n\n### 2.1 Resources Created Inside the Dataset\nPay close attention to whether a token key comes from the **directory/file stem** or the **`name:` attribute** inside the YAML file:\n\n| Token Syntax | Key Derivation Rule | Resolved Value & Where Used |\n| :--- | :--- | :--- |\n| `$project_ids:<project-key>` | **File stem** of `projects/**/<project-key>.yaml` or `folders/**/<project-key>.yaml` (e.g., `projects/net-core-0.yaml` $\\rightarrow$ `$project_ids:net-core-0`). | Project ID. Used in `project_id:` across VPCs, DNS zones, NCC hubs, NVAs, and `ncc-hubs/*.yaml` `auto_accept`. |\n| `$folder_ids:<folder-key>` | **Relative directory path** under `folders/` (or passed from Stage 0 `var.folder_ids`, e.g., `$folder_ids:networking`). | Folder ID (`folders/123456`). Used in `projects/*.yaml` `parent:` and `firewall-policies/*.yaml` `parent_id:` / `attachments:`. |\n| `$networks:<vpc-key>` | **Directory path** under `vpcs/<vpc-key>/.config.yaml` (e.g., `vpcs/prod/.config.yaml` with `name: prod-0` is referenced as **`$networks:prod`**, NOT `prod-0`). | VPC self-link (or name in VPNs). Used in `peering_config`, `dns/zones/*.yaml` `client_networks` & `peer_network`, `firewall-policies/*.yaml` `attachments`, and `nvas/*.yaml` `nics[].network`, `ilb_config.forwarding_rules[].network`, & `ncc_ra_config.spokes[].network`. |\n| `$subnets:<vpc-key>/<region>/<subnet-name>` | `<vpc-key>` (directory under `vpcs/`) + **resolved region name** (e.g. `europe-west1`, NOT the `$locations:` alias) + **`name:` attribute** inside the subnet YAML. *(Resolves regular and `private_nat` subnets only, not `proxy_only` or `psc` subnets).* | Subnet self-link. Used in `addresses.yaml` (`subnetwork` / `subnet_self_link`), `nvas/*.yaml` (`nics[].subnet`), Cloud NAT `subnetworks`, and Cloud Router `interfaces[].subnet`. |\n| `$ncc_hubs:<hub-name>` | Required **`name:` attribute** inside `ncc-hubs/<file>.yaml`. | NCC Hub ID. Used in `vpcs/<vpc>/.config.yaml` `ncc_config.hub`, `vpns`/`vlan-attachments` `ncc_spoke_config.hub`, and `nvas/*.yaml` `ncc_ra_config.spokes[].hub`. |\n| `$ncc_groups:<hub-name>/<group-key>` | `<hub-name>` (`name:` in `ncc-hubs/*.yaml`) + map key under `groups:` (e.g., `$ncc_groups:hub/default` or `$ncc_groups:transit/center`). | NCC Group ID. Used in `ncc_config.group`, `ncc_spoke_config.group`, and `ncc_ra_config.spokes[].group`. |\n| `$routers:<vpc-key>/<router-key>` | `<vpc-key>` (directory under `vpcs/`) + map key under `routers:` in `vpcs/<vpc-key>/.config.yaml`. | Cloud Router name. Used in `vpns/*.yaml` and `vlan-attachments/*.yaml` under `router_config.name` (with `create: false`). |\n| `$vpn_gateways:<vpc-key>/<vpn-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vpns/<file>.yaml` (e.g., `vpcs/hub/vpns/onprem.yaml` with `name: to-onprem` $\\rightarrow$ `$vpn_gateways:hub/to-onprem`). | HA VPN Gateway ID. Used in `peer_gateways.<key>.gcp` for GCP-to-GCP HA VPNs. |\n| `$vlan_attachments:<vpc-key>/<att-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vlan-attachments/<file>.yaml`. | VLAN Attachment ID. Used in `vpcs/<vpc-key>/attachment-groups/*.yaml` `attachments:`. |\n| `$attachment_groups:<vpc-key>/<grp-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/attachment-groups/<file>.yaml`. | Interconnect Attachment Group ID. Used in `vpcs/<vpc-key>/vlan-attachments/*.yaml` `attachment_group:`. |\n| `$addresses:<vpc-key>/<addr-key>` | `<vpc-key>` + map key under `internal:` in `vpcs/<vpc-key>/addresses.yaml` (e.g., `internal: { app-ilb: ... }` in `vpcs/prod/addresses.yaml` $\\rightarrow$ **`$addresses:prod/app-ilb`**, with NO `/internal/` segment). | Reserved internal IP address. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop`) and `policy_based_routes` (`next_hop_ilb_ip`). |\n| `$addresses:<vpc-key>/<nva-name>` | `<vpc-key>` (from `ilb_config.forwarding_rules[].network`) + **`name:` attribute** of the NVA in `nvas/<file>.yaml`. | NVA Internal Passthrough NLB VIP in `<vpc-key>`. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop_type: ilb`, `next_hop:`) and `policy_based_routes` (`next_hop_ilb_ip:`). |\n| `$nva_instances:<nva-name>-<zone>-<index>` | `<nva-name>` (`name:` in `nvas/*.yaml`) + `<zone>` key in `instance_groups:` + `0`-based instance index (for `auto_create_instances > 0`, e.g., `$nva_instances:primary-b-0`). | NVA VM instance self-link. Used in explicit Cloud Router `peers[].router_appliance_instance` (`vpcs/<vpc>/.config.yaml`) and `ncc_ra_config.spokes[].instances[].vm`. |\n| `$nva_addresses:<nva-name>-<zone>-<index>/<vpc-key>` | Instance key (`<nva-name>-<zone>-<index>`) + `<vpc-key>` of the attached NIC (e.g., `$nva_addresses:primary-b-0/routing`). | NVA VM NIC internal IP in `<vpc-key>`. Used in explicit Cloud Router `peers[].address` and `ncc_ra_config.spokes[].instances[].ip`. |\n\n### 2.2 Stage Context & External Variables (`defaults.yaml` & `var.context`)\n- **`$locations:<key>`:** Defined under `context.locations` in `defaults.yaml` (e.g., `ew1: europe-west1`, `ew8: europe-west8`). Supported across subnets, routers, Cloud NAT, NVAs, VPNs, VLAN attachments, firewall policies, addresses, routes, and `policy_based_routes`.\n- **`$cidr_ranges:<key>` & `$cidr_ranges_sets:<key>`:** Defined under `context.cidr_ranges` and `context.cidr_ranges_sets` in `defaults.yaml`.\n  - `$cidr_ranges_sets:<key>` is resolved in **both** `firewall-policies/*.yaml` and VPC `vpcs/<vpc>/firewall-rules/*.yaml`.\n  - `$cidr_ranges:<key>` is resolved in `vpcs/<vpc>/.config.yaml` `routes` and `policy_based_routes`, as well as subnet configs and `firewall-rules/*.yaml`.\n- **`$custom_roles:<key>`, `$iam_principals:<key>`, `$email_addresses:<key>`, `$kms_keys:<key>`, `$tag_keys:<key>`, `$tag_values:<key>`, `$vpc_sc_perimeters:<key>`:** Defined in `defaults.yaml` `context` (except `folder_ids`, which comes from `var.folder_ids` or `var.context.folder_ids`) or passed from root stage variables (`var.custom_roles`, `var.iam_principals`, `var.service_accounts`, `var.kms_keys`, `var.tag_keys`, `var.tag_values`, `var.perimeters`).\n- **`$security_profile_groups:<key>`:** Passed via `var.security_profile_groups` (or `defaults.yaml` `context.security_profile_groups`). Used in `firewall-policies/*.yaml` rules with `action: apply_security_profile_group`.\n\n### 2.3 Common Schema & Factory Gotchas\n- **Unique Subnet Filename Stems per VPC (`vpcs/<vpc>/subnets/`):** `modules/net-vpc` keys raw subnet YAML files by `basename(file)` (stripping any `<region>/` subdirectory). Never create two subnet files with the same filename in different regional folders of the same VPC (e.g., `subnets/ew1/default.yaml` and `subnets/ew8/default.yaml` will collide); always include the region or tier in the filename (`subnets/ew1/prod-default-ew1.yaml`).\n- **Subnet `secondary_ip_ranges` (`subnet.schema.json`):** Each secondary range entry under `secondary_ip_ranges:` must be an **object** with `ip_cidr_range` (or `reserved_internal_range`), never a bare CIDR string:\n  ```yaml\n  secondary_ip_ranges:\n    pods:\n      ip_cidr_range: 10.104.0.0/17\n    services:\n      ip_cidr_range: 10.108.0.0/21\n  ```\n- **VPC `network_attachments` (`vpc.schema.json`):** Under `network_attachments.<key>`, `subnet` must be `<resolved-region>/<subnet-name>` (e.g., `europe-west1/prod-psc-int-ew1`), the connection toggle is `automatic_connection: true|false` (not `automatic_accept`), and `producer_accept_lists` / `producer_reject_lists` take literal project IDs (they do not interpolate `$project_ids:`).\n- **Disabling VLAN Attachments (`vpcs/<vpc>/.config.yaml`):** To disable loading `vlan-attachments/` for a VPC (such as in the reference datasets), set `factories_config: { vlan_attachments: \"none\" }` inside `vpcs/<vpc>/.config.yaml`—there is no `vlan_attachments` key under `var.factories_config.paths` in `.tfvars`.\n- **Firewall Policies (`firewall-policies/*.yaml`) vs. VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n  - `firewall-policies/*.yaml` (`firewall-policy.schema.json`) uses `ingress_rules` / `egress_rules`, `action: allow|deny|goto_next|apply_security_profile_group`, `match.source_ranges`, `match.destination_ranges`, and `match.layer4_configs: [{ protocol: tcp, ports: [\"22\"] }]`.\n  - `vpcs/<vpc>/firewall-rules/*.yaml` (`firewall-rules.schema.json`) uses `ingress` / `egress` maps where each rule defines top-level `source_ranges` / `destination_ranges`, `rules: [{ protocol: tcp, ports: [\"22\"] }]` (with `deny: false` for allow or `deny: true` for deny), and `enable_logging: { include_metadata: false }` (an object, not a boolean).\n\n---\n\n## 3. Generating `2-networking.auto.tfvars`\n\nAlways generate a companion `2-networking.auto.tfvars` file next to (or pointing to) the custom dataset directory:\n\n### 3.1 When Deployed on Top of FAST `0-org-setup`\n`0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` already supply `billing_account`, `organization`, `prefix`, `folder_ids`, `service_accounts`, and `storage_buckets`. The `.tfvars` file only needs `factories_config`:\n\n```hcl\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n### 3.2 When Deployed Standalone (Without `0-org-setup`)\nProvide the required FAST root variables (`billing_account`, `organization`, `prefix`) and any referenced `$folder_ids` or `$project_ids` (if reusing existing projects). In `defaults.yaml`, either omit `output_files.storage_bucket` (or use `output_files.local_path`) or pass `storage_buckets` in `.tfvars` so `apply` does not attempt to write to an unresolved `$storage_buckets:iac-0/iac-outputs` string:\n\n```hcl\nbilling_account = {\n  id = \"012345-6789AB-CDEF01\"\n}\n\norganization = {\n  domain      = \"example.com\"\n  id          = 123456789012\n  customer_id = \"C01234567\"\n}\n\nprefix = \"myorg\"\n\nfolder_ids = {\n  networking = \"folders/1234567890\"\n}\n\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n---\n\n## 4. Phase 3 Execution: Validation & `terraform plan`\n\nAfter writing the YAML files into the user's custom dataset directory:\n\n1. **Run the Bundled Validator Script (Mandatory Gate):**\n   ```bash\n   uv run skills/fast/networking/scripts/validate_dataset.py <path/to/custom-dataset>\n   ```\n   - This script validates:\n     1. Every YAML file against its JSON schema in `fast/stages/2-networking/schemas/`.\n     2. All `$token` references, strict CIDR syntax (no host bits set), intra-VPC and cross-VPC CIDR overlaps, unique subnet file basenames per VPC, reciprocal VPC peerings, unique firewall rule names per project, and NVA (`ilb_config` / `ncc_ra_config` / BGP / `routes` vs. `advertise`) guardrails.\n     3. Isolated `terraform init -backend=false`, `terraform validate`, and `terraform plan -refresh=false` in a temporary directory using synthetic FAST variables.\n   - Fix any reported errors or unintended warnings and re-run until `validate_dataset.py` exits with code `0`.\n\n2. **Write `<custom-dataset>/README.md`:**\n   Include a concise `README.md` in the custom dataset folder summarizing:\n   - Topology overview and regional IPAM & VPC summary table.\n   - **Downstream Stage Handoffs** (any requested ALBs, Cloud NGFW Enterprise security profile groups, GKE secondary ranges, PSC service attachments, or PSA databases that follow-up stages will deploy on top of this plumbing).\n","skills/fast/prerequisites/SKILL.md":"---\nname: fast-prerequisites\ndescription: Guides the user step-by-step through the prerequisites for the FAST 0-org-setup stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for 0-org-setup or bootstrap the FAST landing zone.\n---\n\n# FAST 0-org-setup Prerequisites Guide\n\n## Core Principles & Execution Rules\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):** \n> To ensure the human user always knows where they are in the workflow and can immediately spot if you skip steps or make false assumptions, **EVERY SINGLE response you output MUST start with this progress block**. Do not omit it, do not shorten it, and do not skip step sequences.\n>\n> Format to prepend to EVERY message:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (Step 1/2: Target Environment Selection - IN PROGRESS)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Not started)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n>\n> As steps are completed, update the bracketed lines to show completed steps or current active step, e.g.:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (2/2 steps completed)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Step 1/2: Admin Principal Definition - IN PROGRESS)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n\n> [!CRITICAL]\n> **Understanding Turn Boundaries:** You are running in a turn-based execution environment. \n> - You receive one user message, then you can think and run tools.\n> - Once you decide you need input from the user (e.g. to choose an environment or confirm a principal), you MUST output your question and **STOP execution immediately (do not call more tools)**. \n> - You **CANNOT** receive the user's answer in the same turn. \n> - You **MUST NOT** simulate, guess, or assume the user's response in your subsequent thinking blocks during the same turn. You must wait for the next turn to receive the actual input.\n>\n> **Interactive Questions (`ask_user` / `ask_question`):** Use the `ask_user` (or `ask_question`) tool whenever available to prompt the user, especially whenever multiple options or choices are presented.\n>\n> **Do NOT Skip Steps or Make Assumptions:** You MUST NOT skip any phases or steps, even if you think they are redundant or if you find information on the system (like active credentials) that suggests a step is already complete. You MUST execute every step sequentially, in order, and wait for explicit user input/confirmation at each step boundary.\n>\n> **MANDATORY START POINT (TURN 1):** You MUST explicitly ask the user to choose their target environment (Standard GCP or GCD) in your first turn. Do NOT check active credentials or run background commands to bypass this step. The environment selection is a fundamental gateway that dictates all downstream resources, variables, and configurations. Proceeding without explicitly getting this choice in Turn 1 is a critical failure.\n>\n> **Strictly One Question at a Time:** You MUST NOT bundle multiple questions or steps together in a single response. Ask exactly one question, wait for the user's answer, and only then proceed to the next question or action.\n>\n> **Sandbox Awareness:** You are running inside an isolated, sandboxed temporary workspace (e.g., `/tmp/gemini_harness_*`). Whenever creating local files, configuration directories (like `custom-fast-config` or `fast-config`), or checking defaults, you MUST do so strictly relative to your current workspace directory (CWD). NEVER try to directly read or write to `/home/ludomagno/` or other external folders, as your file tools are sandboxed and will fail with permission errors.\n>\n> **Step-by-Step Execution:** Never implement a single \"magical\" flow. Go through each step one at a time, explaining context and asking for confirmation.\n3. **Execution Choice:** Respect the user's execution preference (automatic via `run_command` vs. manual copy/paste) throughout the entire workflow unless the user explicitly instructs you to change it. This preference will be gathered during Phase 1.\n4. **File Modifications:** Always use `replace` or `write_file`. **Never** use opaque shell commands (like `sed`, `echo >>`, or `cat <<EOF >>`). Show proposed edits and ask for confirmation before applying them so the user can see what we're doing.\n5. **YAML Validation:** Validate generated YAML using `yamllint -c .yamllint --no-warnings <file>`. If the command is not available, ask the user if they prefer to a) install it, b) have you install it (`pip install yamllint`), or c) skip validation.\n6. **Resuming Mid-Flow:** If the user is resuming a previously interrupted session, ask them which Phase or Step they left off at, or assess the current state by reading previously generated files (e.g., `defaults.yaml`, `0-org-setup.auto.tfvars`). Read the corresponding reference file for the identified phase and resume execution directly from that point.\n\n## Workflow Map\n\nGuide the user through the following sequence strictly in order. **Before starting a phase, read its corresponding reference document to get the exact instructions, commands, and logic.**\n\n### Phase 1: Environment & Authentication\n*Description:* Determine the target environment (Standard GCP or GCD) and ensure the user is properly authenticated.\\\n*Reference: [Environment & Authentication](references/phase1-env-and-auth.md)*\n- **Step 1:** Environment Assessment & Initialization (Standard vs GCD)\n- **Step 2:** Authentication\n\n### Phase 2: Admin Principal & Baseline Info\n*Description:* Define the core administrative identity and gather essential baseline data like Organization and Billing IDs.\\\n*Reference: [Admin Principal & Baseline Info](references/phase2-admin-and-baseline.md)*\n- **Step 3:** Admin Principal Definition (Group vs Single User)\n- **Step 4:** Baseline Information Gathering (Org ID, Billing ID, Billing Access Scenarios)\n\n### Phase 3: Bootstrap Project & IAM\n*Description:* Assign the necessary organization-level IAM roles and set up a temporary project for API quota tracking.\\\n*Reference: [Bootstrap Project & IAM](references/phase3-bootstrap-and-iam.md)*\n- **Step 5:** IAM Role Assignments\n- **Step 6:** Bootstrap Project Setup (Creation and API enablement)\n\n### Phase 4: Configuration & Wrap-up\n*Description:* Generate the FAST dataset configuration, handle existing organization policies and essential contacts, and prepare for the final Terraform apply.\\\n*Reference: [Configuration & Wrap-up](references/phase4-config-and-wrapup.md)*\n- **Step 7:** Configuration Generation (Datasets, defaults.yaml, local paths)\n- **Step 8:** Organization Policy Import Check & Essential Contacts\n- **Step 9:** Wrap-up & Apply\n","skills/fast/prerequisites/TESTING.md":"# FAST 0-org-setup Prerequisites - Testing Workflows\n\nThis document outlines the various testing workflows and combinations for the `fast-prerequisites` skill to ensure all branches of the skill's logic are covered.\n\n## Core Variables & Decision Points\n\n1. **Environment:** Standard GCP vs. Google Cloud Dedicated (GCD)\n2. **GCD Universe (if applicable):** S3NS (France), Berlin (Germany), or Custom\n3. **Execution Mode:** Auto (Gemini CLI runs commands) vs. Manual (Output commands for user)\n4. **Authentication State:** Already authenticated vs. Needs authentication\n5. **Admin Principal Approach:** Group (Approach A) vs. Single User (Approach B)\n6. **Billing Account Access Level:** Billing Administrator vs. Billing User vs. No Access\n7. **Bootstrap Project:** Pre-existing (with or without API check) vs. New project created by user\n8. **Configuration / Dataset:** Standard GCP datasets vs. `classic-gcd` dataset\n9. **Org Policy Import Check & Essential Contacts:** Pre-existing policies found vs. No policies found; Essential Contacts configured (deploying user vs. custom email) vs. Essential Contacts policy set to allow all\n\n## Comprehensive Test Scenarios\n\n### Scenario 1: Standard GCP - The \"Simple User\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Manual\n* **Authentication:** Needs authentication (Standard auth flow)\n* **Admin Principal:** Single User (current authenticated user)\n* **Information Gathering:** Direct input of Org ID and Billing ID\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** New project (user creates, CLI outputs full API enable command)\n* **Configuration:** Default local path, standard dataset, additional context added\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n### Scenario 2: Standard GCP - The \"Developer\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Auto\n* **Authentication:** Already authenticated\n* **Admin Principal:** Group (`foo-owners@example.com`)\n* **Information Gathering:** Uses \"list\" to fetch Org ID and Billing ID\n* **Billing Access:** Billing User (requires disabling billing YAML)\n* **Bootstrap Project:** Pre-existing project (auto-check and enable missing APIs)\n* **Configuration:** Custom local path, standard dataset, no additional context\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n#### Notes\n\n### Scenario 3: GCD (S3NS) - Automated Setup\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** S3NS (France)\n* **Execution Mode:** Auto\n* **Authentication:** Needs authentication (WIF login flow)\n* **Admin Principal:** Group\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** Pre-existing project (skip API check, enable all)\n* **Configuration:** `classic-gcd` dataset (fixed), auto-mapped universe regions, providers.tf generated\n* **Org Policies:** No policies exist\n\n### Scenario 4: GCD (Custom) - Manual Setup with Restrictions\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** Custom (manually input all 5 variables)\n* **Execution Mode:** Manual\n* **Authentication:** Already authenticated\n* **Admin Principal:** Single User\n* **Billing Access:** No Access (requires warning and manual continuation)\n* **Bootstrap Project:** New project\n* **Configuration:** `classic-gcd` dataset, custom local path\n* **Org Policies:** Some policies exist\n","skills/fast/prerequisites/references/phase1-env-and-auth.md":"# Phase 1: Environment & Authentication\n\n### Step 1: Environment Assessment & Initialization\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your very first Turn 1 response prepends the progress block showing:\n>   - Phase 1: Environment & Authentication (Step 1/2: Target Environment Selection - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n>\n> **MANDATORY START POINT (TURN 1):** \n> - You MUST begin the entire workflow by asking the user to clarify their target environment (**Standard GCP** or **Google Cloud Dedicated (GCD)**). \n> - You **MUST NOT** run any `gcloud` commands, check active credentials, or proceed to Step 2 or any other Phase/Step in Turn 1. \n> - You **MUST** stop execution immediately after asking this question and wait for the user's response.\n> - Do NOT assume, pre-fill, or guess the environment based on local config files, active credentials, or background command success.\n>\n> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) in a subsequent turn and wait for their response.\n\n1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.**\n2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.**\n3. *If GCD is selected*, ask the user if they are working in one of the known universes: **S3NS (France)** or **Berlin (Germany)**.\n   - *If S3NS:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.s3nscloud.fr`\n     - Universe API Domain: `s3nsapis.fr`\n     - Universe Name: `s3ns`\n     - Universe Prefix: `s3ns`\n     - Universe Region: `u-france-east1`\n   - *If Berlin:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.berlin-build0.goog`\n     - Universe API Domain: `apis-berlin-build0.goog`\n     - Universe Name: `berlin`\n     - Universe Prefix: `eu0`\n     - Universe Region: `u-germany-northeast1`\n   - *If neither (Custom):* Gather the 5 universe-specific details manually from the user.\n   - *Action:* Present the final list of the 5 universe values to the user for review. Ask for explicit confirmation and offer them the opportunity to change any of the values before proceeding.\n\n### Step 2: Authentication\n\n> [!CRITICAL]\n> **DO NOT START STEP 2 PREMATURELY:** You MUST NOT check authentication, run `gcloud config list`, or execute Step 2 until Step 1 (Environment Assessment & Initialization) has been completely finished and confirmed by the user in previous turns.\n\n1. Ask the user if they are already authenticated with Google Cloud using the correct principal.\n   - *If yes:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use.\n     - *If they confirm (and Step 1 is already completed):* Proceed directly to Phase 2 (Step 3).\n     - *If they do not confirm:* Proceed with the authentication steps below.\n   - *If no:* Proceed with the authentication steps below.\n2. *Standard GCP:* Provide or execute the command:\n   ```bash\n   gcloud auth login\n   gcloud auth application-default login\n   ```\n3. *GCD:* Automate or guide the user through WIF login. Ask for the workforce pool audience string first, then generate the configuration:\n   ```bash\n   # (Use the gathered GCD variables to fill placeholders)\n   gcloud config configurations create <UNIVERSE_NAME>\n   gcloud config configurations activate <UNIVERSE_NAME>\n   gcloud config set universe_domain <UNIVERSE_API_DOMAIN>\n\n   gcloud iam workforce-pools create-login-config <AUDIENCE> \\\n     --universe-cloud-web-domain=\"<UNIVERSE_WEB_DOMAIN>\" \\\n     --universe-domain=\"<UNIVERSE_API_DOMAIN>\" \\\n     --output-file=\"/tmp/wif-login-config-<UNIVERSE_NAME>.json\" \\\n     --activate\n\n   gcloud auth login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json --no-launch-browser\n   gcloud auth application-default login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json\n   ```\n4. Explicitly ask the user to confirm they have successfully authenticated before moving to the next phase.\n","skills/fast/prerequisites/references/phase2-admin-and-baseline.md":"# Phase 2: Admin Principal & Baseline Info\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your responses in Phase 2 prepend the progress block showing:\n>   - Phase 1: Environment & Authentication (2/2 steps completed)\n>   - Phase 2: Admin Principal & Baseline Info (Step 1/2: Admin Principal Definition - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n\n### Step 3: Admin Principal Definition\n\n1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter.\n2. Determine the Admin Principal approach by asking the user to choose between two options:\n   - **Approach A (Preferred): Use a pre-created Group.**\n     - *Action:* Explain that using a group (e.g., `group:gcp-organization-admins@example.com`) is the standard and preferred way. **Crucially, clarify that the group provided MUST be a group that the user's current authenticated identity belongs to**, otherwise they will lock themselves out.\n     - *Action:* Ask the user to provide the group email address.\n     - *Action:* Explicitly ask the user to confirm that their current identity (the one they just authenticated with) is already a member of this group.\n     - *Action:* If the user answers \"No\" to the membership confirmation, **DO NOT PROCEED**. Inform the user that proceeding will lock them out. Ask them to either authenticate with an identity that *is* a member of the group (and restart the authentication step), or provide a different group that their current identity belongs to.\n   - **Approach B (Fallback): Use a Single User.**\n     - *Action:* Explain that this flow uses a single user as the sole GCP Org Admin, but more can be added later.\n     - *Action:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve their current authenticated principal.\n     - *Action:* Show the user their current principal and explicitly ask them to confirm this is the identity they want to use as the Admin Principal.\n\n### Step 4: Baseline Information Gathering\n\n1. Gather baseline information required for `0-org-setup`:\n   - Organization ID (and the associated Directory Customer ID, which is important for Standard GCP but not required for GCD)\n   - Billing Account ID (Mandatory for subsequent stages, even if not required for the GCD temporary project)\n   *Action:* When prompting the user for the Organization ID and Billing Account ID, explicitly instruct them in the prompt/question that they can leave the field blank (or type \"list\") to have you automatically run the relevant `gcloud` command (`gcloud organizations list --format=\"json\"` or `gcloud beta billing accounts list`). Also, instruct them that they can type a keyword to filter the list.\n   *Action:* If the user leaves the field blank or types \"list\", run the `gcloud` command without filters. If the user types a keyword that is not a valid Organization ID (numeric) or Billing Account ID (e.g., `012345-6789AB-CDEF01`), run the `gcloud` command and use that keyword to filter the results using a **case-insensitive** regex match. Ensure the regex pattern is enclosed in single quotes within the filter argument (e.g., `--filter=\"displayName~'(?i)KEYWORD'\"`). Do NOT use `*` wildcards in the filter.\n   *Action:* If the filtered `gcloud` command returns no results, inform the user and ask if they want to provide a different keyword or fetch all items (run without a filter).\n   *Action:* Once you have results for Organizations (filtered or unfiltered), extract the Organization ID, the Display Name (domain), and the Directory Customer ID (found in `owner.directoryCustomerId` in the JSON output). Sort them alphabetically by the display name, and then present the results for the user to make their selection. Note the Organization ID, Domain, and Customer ID for Phase 4. For Billing Accounts, do the same sorting and selection.\n2. Determine the **Admin Principal's** access level to the provided Billing Account ID. Ask which of the following three scenarios applies to the Admin Principal (not necessarily the current user):\n   - **Scenario 1 (Billing Administrator):** The Admin Principal has `roles/billing.admin`.\n     - *Action:* Ask a follow-up question: \"Is your billing account managed by the same organization where we are installing FAST, or outside of it? (You can check this in the Google Cloud Console by going to Billing -> using the organization picker on top -> checking if the account is listed under this organization).\"\n     - *If Inside the Org:* Note that `roles/billing.admin` WILL be assigned at the Organization level in Step 6. Instruct the user that we will deactivate the billing factories path for now, but if account-level IAM also needs to be managed via FAST later, they can reactivate the path and use the billing YAML to do it.\n     - *If Outside the Org:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6.\n   - **Scenario 2 (Billing User):** The Admin Principal has `roles/billing.user` but NOT admin rights.\n     - *Action:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6. Either disable the billing YAML via the `factories_config` variable or comment it out, since the Admin Principal cannot control IAM on the account.\n     - *Action:* **Explain to the user:** The service accounts for IaC (and therefore the provider switch and subsequent stages, except for VPC-SC) will not be operative until the correct billing permissions have been assigned to them outside of FAST.\n   - **Scenario 3 (No Access):** The Admin Principal has absolutely no rights on the billing account.\n     - *Action:* **Clearly state:** This scenario is mostly used for development purposes, is strongly discouraged, and requires advanced Terraform skills and FAST knowledge to proceed.\n","skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md":"# Phase 3: Bootstrap Project & IAM\n\n### Step 5: IAM Role Assignments\n\n1. Grant the following roles to the chosen Admin Principal at the Organization level. **CRITICAL:** Only include `roles/billing.admin` in this list if the user selected Scenario 1 (Billing Administrator) AND confirmed the billing account is managed Inside the Organization in Step 4.\n   **CRITICAL EXECUTION RULE:** Do NOT create temporary bash scripts (e.g., `assign_roles.sh`) to execute this loop. You MUST execute the `for` loop inline directly using the `run_shell_command` tool, or output the exact inline loop for the user to copy/paste.\n   ```bash\n   # Roles to assign:\n   # [roles/billing.admin] <-- CONDITIONAL (See above)\n   # roles/logging.admin\n   # roles/iam.organizationRoleAdmin\n   # roles/orgpolicy.policyAdmin\n   # roles/resourcemanager.folderAdmin\n   # roles/resourcemanager.organizationAdmin\n   # roles/resourcemanager.projectCreator\n   # roles/resourcemanager.tagAdmin\n   # roles/owner\n\n   # Loop example for the user or tool execution:\n   for role in [ROLES_LIST]; do\n     gcloud organizations add-iam-policy-binding <ORG_ID> \\\n       --member=\"<ADMIN_PRINCIPAL>\" --role=\"$role\" --condition=None\n   done\n   ```\n\n### Step 6: Bootstrap Project Setup\n\n1. Explain that a temporary bootstrap project is required to track API quota before organization policies are fully established.\n2. Ask the user if they already have a suitable project they can use for this purpose.\n   - *If yes:* Ask if this project is already configured as the active project in `gcloud`. If the user does not know, run `gcloud config list project --format=\"value(core.project)\"` to check for them.\n     - If it is already configured, fetch the Project ID using `gcloud config list project --format=\"value(core.project)\"`. Explicitly ask the user to confirm if this fetched Project ID is the one they want to use. **Only if they answer \"No\" to this confirmation**, ask them to provide the correct Project ID.\n     - If it is not configured, ask the user to provide the Project ID.\n   - *If no:* Ask the user to use the Cloud Console to create a temporary project (must be linked to the billing account). Ask them to provide the new Project ID once created.\n3. Once the Project ID is provided or fetched, ensure it is set as the default project. If it is not already set, run:\n   ```bash\n   gcloud config set project <TEMP_PROJECT_ID>\n   ```\n4. Enable the required baseline APIs on the project:\n   - The required APIs are: `bigquery.googleapis.com`, `cloudbilling.googleapis.com`, `cloudresourcemanager.googleapis.com`, `essentialcontacts.googleapis.com`, `iam.googleapis.com`, `logging.googleapis.com`, `orgpolicy.googleapis.com`, `serviceusage.googleapis.com`.\n   - *If the project was pre-existing:* Ask the user if they want you to check which services are already enabled.\n     - *If yes:* Run `gcloud services list --enabled --format=\"value(config.name)\"` to get the current list. Compute the delta between the enabled services and the required list. Only run `gcloud services enable <MISSING_APIS>` for the ones that are missing.\n     - *If no:* Run the full `gcloud services enable` command for all required APIs.\n   - *If the project is new:* Run the full `gcloud services enable` command for all required APIs.\n","skills/fast/prerequisites/references/phase4-config-and-wrapup.md":"# Phase 4: Configuration & Wrap-up\n\n### Step 7: Configuration Generation\n\n1. **Explain Datasets:** Briefly explain to the user that FAST uses \"datasets\" (collections of YAML files) that fully describe the design, architecture, resources, and policies applied.\n2. **Select the Dataset:** Ask the user to select the dataset they want to use for their landing zone design.\n   - *If GCD:* Explicitly state that the `classic-gcd` dataset must be used for GCD installations.\n   - *If Standard GCP:* Offer the available datasets and ask them to choose one. **Crucially, only search for available datasets within the `fast/stages/0-org-setup/datasets/` directory.** Do not search across the entire repository or other FAST stages. Provide a one-line description below each dataset when presenting the options (e.g., \"classic: The standard FAST landing zone architecture\", \"hardened: A more restrictive, hardened landing zone architecture\").\n3. **Explain Defaults Configuration:** Explain to the user that we are starting the configuration of the `defaults.yaml` file, which drives the static configuration of the dataset by providing the Org ID, Billing ID, user-specified locations, and any static values they need to bring in from the outside (like additional IAM principals used in the YAML files to assign IAM roles).\n4. **Determine Locations:** FAST uses a set of locations for different services.\n   - *If GCD:* The region is fixed based on the universe selected in Phase 1. Set the `logging` location to `global` and all other required locations to the Universe Region. Do not ask the user to choose; simply show them the configured locations.\n   - *If Standard GCP:* Check the `fast/stages/0-org-setup/schemas/defaults.schema.json` to identify the required location keys (e.g., `bq`, `gcs`, `logging`, `pubsub`). First, ask the user to provide a \"base location\" (e.g., `europe-west1`), explaining that submitting without answering will confirm the default value. Even if the user confirms the base location, you must then ask if they need to override the location for any individual services.\n5. **Determine Local Path:** Explain to the user that FAST generates provider configurations and other files that need to be stored outside the repository. This is defined by the `output_files.local_path` setting. Propose a default path based on the chosen prefix (e.g., `~/fast-config/<prefix>`) and ask the user to confirm or provide a different path. **Crucially, if the user provides a relative path (e.g., `custom-fast-config` or `./custom-fast-config`), use it exactly as provided relative to the current working directory. Do not automatically prepend `~/` to their input.** Once confirmed, create this directory using `mkdir -p <LOCAL_PATH>`.\n6. **Ask for Additional Context:** Ask the user if there are any other static values they want to bring in from outside to be referenced in the YAML files. Show them the available context keys (excluding `condition_vars`). Provide examples showing that prefixes are mandatory for IAM principals (e.g., `user:foo@example.com`, `group:bar@example.com`). For GCD, also show a `principalSet:` example. **Do not use shell commands like `echo` or `cat` to show this list; output it directly in your chat message.**\n7. **Create Local Directories and Copy Defaults:**\n   - Create the `data/0-org-setup/` and `providers/` directories inside the confirmed `local_path` (`mkdir -p <LOCAL_PATH>/data/0-org-setup/ <LOCAL_PATH>/providers/`).\n   - **Copy** (do not move) the `defaults.yaml` from the chosen dataset folder to `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` using `cp`.\n8. **Edit the Copied Defaults File:** Use the `replace` tool to edit the *copied* `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` file.\n   - Populate `global.billing_account`, `global.organization.id`, `global.organization.domain`, and `global.organization.customer_id` (using the values gathered in Phase 2). Note that `customer_id` may not be present for GCD.\n   - Populate `context.iam_principals.gcp-organization-admins` (using the Admin Principal determined in Phase 2).\n   - Populate the service-specific locations gathered in the previous step into the `context.locations` block and map them appropriately in `projects.defaults.locations`.\n   - Populate `output_files.local_path` with the confirmed path.\n   - If additional context was provided, add it to the `context` block.\n   - *If GCD*, ensure the `overrides.universe` block is present with `domain`, `prefix`, and specific identity overrides.\n9. Run YAML validation: `yamllint -c .yamllint --no-warnings <LOCAL_PATH>/data/0-org-setup/defaults.yaml`. Handle missing tool errors as described in the Core Principles.\n10. **Create Configuration Files:**\n    - Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`).\n    - In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `\"datasets/classic\"`), but the `paths.defaults` must point to the absolute path of the copied defaults file.\n    - *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`.\n11. **Present Configuration and Halt:** Briefly tell the user you have created and validated the baseline configuration files. You MUST stop execution immediately, present the generated files, ask the user if they are ready to proceed with **Step 8 (Organization Policy Import Check & Essential Contacts)**, and wait for their response. Do not proceed to Step 8 or run more tools in this turn.\n\n### Step 8: Organization Policy Import Check & Essential Contacts\n\n1. **Organization Policy Import Check:**\n   - Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported.\n   - Provide (or execute if automatic) the command to list current policies:\n     ```bash\n     gcloud org-policies list --organization=\"<ORG_ID>\" --format=\"value(constraint)\"\n     ```\n   - **STOP Execution and Wait:** You MUST stop execution immediately here, ask the user to run the command, and wait for them to paste the output. Do NOT proceed to Step 9 or update any files until the user has explicitly provided the output of this command.\n   - **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them.\n\n2. **Essential Contacts Handling:**\n   - Explain to the user that FAST Stage 0 datasets configure essential contacts as part of the organization configuration, and simultaneously enforce the Essential Contacts domain restriction organization policy (`essentialcontacts.allowedContactDomains`). Any essential contacts defined must match the allowed domains in the organization policy, otherwise Terraform deployment will fail with policy violation errors.\n   - Ask the user to choose how they want to handle essential contacts:\n     - **Option A (Define Essential Contact & Update Org Policy - Recommended):**\n       - Prompt the user for the essential contact email address.\n       - If the currently authenticated identity / deploying principal (from Phase 1/2) is an email-like principal (e.g. `user@example.com` or `user:user@example.com`), offer the option to reuse it.\n       - If the deploying principal is a Workforce Identity Federation (WIF) identity (i.e. starting with `principal://`), skip this option and prompt the user to provide a valid email address.\n       - Update the essential contact in `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` (under `context.email_addresses` or `projects.defaults.contacts`) using the `replace` tool.\n       - Update the organization policy in `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool so that `essentialcontacts.allowedContactDomains` allows the domain part of the provided email (e.g. adding `@<EMAIL_DOMAIN>` to the allowed values).\n     - **Option B (Allow All Domains in Org Policy):**\n       - If the user does not want to restrict domains or wants to bypass domain restrictions, update `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool to set the policy rule to allow all values (`allow: all: true`), commenting out the existing restricted rule setup. Do NOT delete or empty the file (to keep it as a reference and avoid Terraform failures on empty YAML files).\n\n### Step 9: Wrap-up & Apply\n\n1. **Link Configuration:** Instruct the user to link the final configuration files to the stage directory using the `fast-links.sh` script. They should navigate to `fast/stages/0-org-setup/` and run:\n   ```bash\n   ../../fast-links.sh <LOCAL_PATH>\n   ```\n   Explain that they should then copy and run the commands printed by the script to create the links.\n   **Note:** If they are not on GCD, they can ignore the provider file linking command printed by the script for the first run.\n2. Remind the user that the prerequisite phase is complete.\n3. Instruct them to run `terraform init` and `terraform apply`.\n4. Remind them to delete the temporary project and reset their gcloud default project to `iac-0` after a successful apply. Provide the exact commands for them to copy and use later:\n   ```bash\n   gcloud projects delete <TEMP_PROJECT_ID>\n   gcloud config set project <IAC_PROJECT_ID>\n   ```\n","skills/maintenance/release-process/SKILL.md":"---\nname: release-process\ndescription: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n---\n\n# Cloud Foundation Fabric Release Process\n\nThis skill guides maintainers through the process of cutting a new release for the Cloud Foundation Fabric (CFF) repository.\n\n> **CRITICAL:** Release names and tags MUST ALWAYS be prefixed with `v` (e.g., `v55.1.0`, `v56.0.0`).\n\n## Prerequisites\n\n- **Permissions:** You must have write/release permissions on the repository.\n- **GitHub Token:** A valid GitHub token (can be easily derived by running `gh auth token`).\n- **Environment:** You can run the tools using either `uv` (recommended) or a standard Python virtual environment with `tools/requirements.txt` installed.\n\n## 1. Preparation\n\nStart from the `master` branch and ensure you are up to date:\n\n```bash\ngit checkout master\ngit pull\n```\n\nIdentify the **latest released version** by extracting it from the `default-versions.tf` file:\n\n```bash\nLATEST_RELEASE=$(sed -n 's/^# Fabric release: //p' default-versions.tf)\necho \"Latest release is: $LATEST_RELEASE\"\n```\n\n## 2. Generate and Review Changelog\n\nRun the changelog script, specifying the bump type (e.g., `major`, `minor`, `patch`):\n\n```bash\n# Set your bump type here (major, minor, or patch)\nBUMP_TYPE=\"minor\"\n\n# Option A: Using uv (Recommended)\nuv run tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n\n# Option B: Using a standard virtual environment\n./tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n```\n\n**Review the changes:**\nRun `git diff CHANGELOG.md` and carefully inspect the output. Look for:\n\n- PRs missing labels (they will appear under a \"no subsection\" or similar area).\n- Weird or unclear names in PR titles.\n\n**If you find issues:**\n\n1. Click the PR link in the diff.\n2. Manually fix the labels or title on GitHub.\n3. Re-run the `changelog.py` command above.\n4. Repeat until the changelog is clean.\n\n## 3. Bump Versions\n\nOnce the changelog is correct, extract the newly generated release version from the top of the changelog and bump the repository versions:\n\n```bash\nNEW_RELEASE=$(sed -n 's/^## \\[\\([^]]*\\)\\].* - .*/\\1/p' CHANGELOG.md | head -n 1)\n# Ensure it has the 'v' prefix\nif [[ $NEW_RELEASE != v* ]]; then NEW_RELEASE=\"v$NEW_RELEASE\"; fi\necho \"New release is: $NEW_RELEASE\"\n\n# Option A: Using uv (Recommended)\nuv run tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n\n# Option B: Using a standard virtual environment\n./tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n```\n\n## 4. Commit, Open PR, and Merge\n\nBecause `master` is protected and requires pull requests via the merge queue, create a branch, stage all updated files (`CHANGELOG.md`, `default-versions.tf`, `default-versions.tofu`, `fast_version.txt`, and module `versions.tf`/`versions.tofu` files), and open a PR:\n\n```bash\nBRANCH_NAME=\"$(gh api user -q .login)/prep-$NEW_RELEASE\"\ngit checkout -b \"$BRANCH_NAME\"\ngit add -u\ngit commit -m \"prep $NEW_RELEASE\"\ngit push -u origin \"$BRANCH_NAME\"\n\ngh pr create \\\n  --title \"Prep $NEW_RELEASE\" \\\n  --label \"on:FAST,on:modules,on:tools\" \\\n  --body \"Release preparation for $NEW_RELEASE:\n\n- \\`CHANGELOG.md\\`: generated with \\`tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE\\`\n- version strings bumped to \\`$NEW_RELEASE\\` with \\`tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\\`\n\nNo functional changes; every non-CHANGELOG diff is the release string only.\"\n\n# Queue the PR for auto-merge once approved and checks pass\ngh pr merge --auto\n```\n\nOnce the PR is approved and merged into `master`, switch back to `master`, pull the merged commit, and wait for the GitHub Actions workflows on `master` to finish successfully before proceeding to the next step:\n\n```bash\ngit checkout master\ngit pull\n\n# Watch the latest workflow run on the master branch\ngh run watch $(gh run list --branch master --limit 1 --json databaseId -q '.[0].databaseId')\n```\n\n## 5. Create the GitHub Release\n\nYou can create the release either automatically via the GitHub CLI or manually via the GitHub UI.\n\n### Option A: Automated via GitHub CLI (Recommended)\n\nThis script extracts any \"BREAKING CHANGES\" from the changelog and converts the heading to Title Case.\n\n```bash\n# 1. Extract the section for the new release from CHANGELOG.md\n# We use awk to grab everything from the new release header until the next release header\nRELEASE_NOTES=$(awk \"/^## \\[$NEW_RELEASE\\]/{flag=1; next} /^## \\[v/{flag=0} flag\" CHANGELOG.md)\n\n# 2. Extract just the BREAKING CHANGES section (if any)\nBREAKING_CHANGES=$(echo \"$RELEASE_NOTES\" | awk '/^### BREAKING CHANGES/{flag=1; print; next} /^### /{flag=0} flag')\n\n# 3. Format breaking changes\nif [ -n \"$(echo \"$BREAKING_CHANGES\" | tr -d '[:space:]')\" ]; then\n  # Convert \"### BREAKING CHANGES\" to \"## Breaking Changes\"\n  FORMATTED_BREAKING_CHANGES=$(echo \"$BREAKING_CHANGES\" | sed 's/^### BREAKING CHANGES/## Breaking Changes/')\n  echo \"$FORMATTED_BREAKING_CHANGES\"\nfi\n```\n\n> **CRITICAL:** Show the extracted and formatted breaking changes to the user and wait for their approval before proceeding to create the release.\n\nOnce approved, run the appropriate command to create the release.\n\nIf there were breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes --notes \"$FORMATTED_BREAKING_CHANGES\"\n```\n\nIf there were no breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes\n```\n\n### Option B: Manual via GitHub UI\n\nGo to the [GitHub Releases UI](https://github.com/GoogleCloudPlatform/cloud-foundation-fabric/releases/new) and configure the release:\n\n1. **Tag:** Create a new tag matching the new version (e.g., `v56.0.0`).\n2. **Title:** Use the exact same version string as the tag.\n3. **Release Notes:** Click the **\"Generate release notes\"** button.\n4. **Breaking Changes:** If the `CHANGELOG.md` contains a \"BREAKING CHANGES\" section for this release, copy it, paste it at the **top** of the generated release notes, and change the heading to \"## Breaking Changes\".\n\nClick **Publish release**.\n","tools/README.md":"# Cloud Foundation Fabric (CFF) Tools\n\nThis directory contains utility scripts and tools used to automate linting, formatting, validation, documentation generation, and testing across the Cloud Foundation Fabric repository.\n\n## Categorized Tools\n\n### 1. Documentation Generation\nTools for automatically generating documentation tables or formats from source configurations.\n\n- **[tfdoc.py](./tfdoc.py)**: Automatically generates inputs, outputs, and files documentation tables inside module READMEs.\n- **[schema_docs.py](./schema_docs.py)**: Recursively parses and compiles JSON schemas into readable, markdown-based documentation tables.\n- **[format_tftest.py](./format_tftest.py)**: Formats Terraform code blocks containing `# tftest` directives inside markdown files.\n- **[update_schema_links.py](./update_schema_links.py)**: Updates the schema URLs in YAML modelines across a directory to match a specific CDN or versioned source.\n- **[pre-commit-tfdoc.sh](./pre-commit-tfdoc.sh)**: Pre-commit hook script to run `tfdoc.py` updates and verify README alignment.\n\n### 2. Testing, Planning & Emulation\nTools to run testing sandboxes, parse plans, and simulate/evaluate agent behaviors.\n\n- **[skill-turn-harness/](./skill-turn-harness)**: A hybrid Python/SDK-based test harness for developing, running, and grading Antigravity skills.\n- **[generate_plan_summary.py](./generate_plan_summary.py)**: Generates structured plan summaries of resources and their changes for README examples or `tftest.yaml` tests.\n- **[plan_summary.py](./plan_summary.py)**: An internal helper script to parse, format, and filter Terraform plan structures for automated test assertions.\n- **[create_e2e_sandbox.sh](./create_e2e_sandbox.sh)**: Bootstraps an isolated end-to-end sandbox directory to safely provision and test CFF examples.\n\n### 3. Repository Maintenance & Automation\nTools for version management, automated reviews, GCP service definitions, and release automation.\n\n- **[changelog.py](./changelog.py)**: Automates the generation and maintenance of the `CHANGELOG.md` based on Git diffs and version changes.\n- **[versions.py](./versions.py)**: Synchronizes required engine and provider version constraints across standard provider configuration files in the repository.\n- **[build_service_agents.py](./build_service_agents.py)**: Parses the official Google Cloud documentation to build a structured representation of GCP service agents and their properties.\n- **[pr_review.py](./pr_review.py)**: Leverages the Gemini API to perform automated, context-aware code reviews on pull requests.\n- **[state_iam.py](./state_iam.py)**: Parses and displays authoritative IAM binding configurations directly from a local Terraform state file.\n\n### 4. Linting, Quality & Compliance\nTools for maintaining style guide compliance, licensing, validation, and content/rule verification.\n\n- **[check_boilerplate.py](./check_boilerplate.py)**: Scans files to verify that correct Google license headers and boilerplates are present.\n- **[check_documentation.py](./check_documentation.py)**: Recursively verifies that the variables and outputs tables generated inside module `README.md` files are up-to-date.\n- **[check_links.py](./check_links.py)**: Recursively parses Markdown files to validate external links, internal links, and anchor destinations.\n- **[check_names.py](./check_names.py)**: Evaluates name length constraints and formatting for specified GCP Terraform resources to ensure compatibility.\n- **[check_schema_docs.py](./check_schema_docs.py)**: Recursively checks if the Markdown documentation generated from JSON schemas is fully up-to-date.\n- **[check_yaml_schema.py](./check_yaml_schema.py)**: Validates YAML configuration and factory data files against their defined JSON schema modelines.\n- **[duplicate-diff.py](./duplicate-diff.py)**: Verifies content alignment for files that must remain identical across different stages or modules (e.g. schemas, policies).\n- **[lint.sh](./lint.sh)**: A shell script wrapping boilerplate, YAML, and Terraform style and format checks.\n- **[tflint-fast.py](./tflint-fast.py)**: Runs the `tflint` linter against FAST stages by setting them up in temporary isolated environments.\n","tools/skill-turn-harness/README.md":"# Hybrid Python Test Harness for Antigravity Skills\n\n## Overview\n\nThis project provides a robust, hybrid test harness for developing and evaluating Antigravity skills. It solves the \"inner dev loop\" problem by allowing you to test local, unpacked skills directly against the Antigravity engine (via the Python SDK), while using an LLM to deterministically grade the agent's behavior.\n\n## Table of Contents\n\n- [Prerequisites](#prerequisites)\n- [How to Use](#how-to-use)\n  - [Basic Usage](#basic-usage)\n  - [Command Line Options](#command-line-options)\n  - [Expected Output](#expected-output)\n- [Testing Local Skills (Inner Dev Loop)](#testing-local-skills-inner-dev-loop)\n- [Writing Playbooks](#writing-playbooks)\n- [Running the Pytest Suite](#running-the-pytest-suite)\n- [Writing Playbooks: Three Modes of Testing](#writing-playbooks-three-modes-of-testing)\n\nThe architecture relies on three main components:\n\n- **Orchestrator (Python):** Drives the execution loop, reads YAML playbooks, and manages isolated workspaces for each test run to prevent session caching issues.\n- **Execution Target (Antigravity SDK):** The skill is executed using the `google-antigravity` Python SDK, which manages the localharness engine in-process. This eliminates the dependency on a globally installed CLI.\n- **Evaluator (Gemini API):** The semantic evaluation of the agent's output is performed via direct API calls to `gemini-2.5-flash` using `google-genai`. This bypasses brittle string-parsing and guarantees structured JSON output (Pass/Fail + Reasoning).\n\n## Prerequisites\n\nYou can run the harness directly using `uv` (recommended), which will automatically handle downloading and running with the required dependencies:\n\n```bash\nuv run harness.py playbooks/my-playbook.yaml\n```\n\nAlternatively, ensure you have a Python virtual environment set up with the required dependencies:\n\n```bash\npip install google-antigravity google-genai pydantic pyyaml click pytest\n```\n\nYou also need your Gemini API key available in your environment, or stored in `~/.gemini/key.env`:\n\n```bash\nexport GEMINI_API_KEY=\"your_api_key_here\"\n```\n\n## How to Use\n\nThe main entry point is the `harness.py` CLI tool.\n\n> [!NOTE]\n> All commands in this guide assume you are running from the `tools/skill-turn-harness` directory. If running from the repository root, prefix paths accordingly (e.g., `python3 tools/skill-turn-harness/harness.py ...`).\n\n### Basic Usage\n\nTo run a test, provide a YAML playbook:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml\n```\n\n### Command Line Options\n\n- `playbook` (Required): The path to the YAML playbook defining the test steps.\n- `--log-dir <path>` (Optional): The directory where the harness will write detailed Markdown logs, session JSONs, and JSON failure dumps. Defaults to `./logs`.\n- `--skill-src <path>` (Optional): The path to a local, unpacked skill directory. See the \"Testing Local Skills\" section below for details.\n- `--env-file <path>` (Optional): The path to a standard `.env` file containing key-value pairs (e.g. `MY_SECRET=123`). This is used for secure string substitution within your playbook steps.\n- `--keep-workspace` (Optional): Preserve the temporary workspace directory (`/tmp/gemini_harness_*`) after execution to inspect files generated by the agent.\n- `--agent-model <model>` (Optional): Override the model the agent uses (e.g., `gemini-2.5-pro`). Overrides playbook definition.\n- `--evaluator-model <model>` (Optional): Override the model the test harness uses to grade and simulate (e.g., `gemini-2.5-flash`). Overrides playbook definition.\n- `--max-deviations <number>` (Optional): Set the maximum number of minor deviations/mistakes (such as rule violations or incorrect tool calls) the agent can make during autonomous/hybrid mode before the harness fails the test run. Defaults to `3`.\n- `--debug` (Optional): Enable verbose debug logging for the SDK (e.g., WebSocket traffic).\n\n⚠️ **Security Warning regarding Logs:**\nIf your playbooks require secrets (like API keys or passwords) via the `env` array, the harness will substitute them before executing the CLI. Because the harness traces all inputs and outputs for debugging, **these substituted secrets will be written in plain text** to your `logs/` directory.\nA default `.gitignore` is provided in the `logs/` directory to prevent committing these files, but care should still be taken to avoid leaking secrets into your repository.\n\n### Expected Output\n\nThe harness executes the playbook, rendering thoughts and tool calls in real-time, and streams the results to the console with active context usage stats:\n\n```text\n--- Tuning: FAST Setup PoC | Workspace: /tmp/gemini_harness_abc123 ---\n\n[Step 1]\nTester:\nHi, please activate the fast-setup-poc skill and let's configure FAST.\n  🧠 Thinking:\n  Let's activate the fast-setup-poc skill and check the requirements.\n  🛠️ [Tool Call]: list_directory(path=.)\n  ...\n✅ [PASS Step 1]: The agent greeted the user ('Hi'), confirmed it was configuring FAST, and asked for the Project ID. All parts of the objective were fulfilled.\n\n[Step 2] [Context: 4,512]\nTester:\nmy-super-project-123\n...\n\n✅ [SUCCESS] Playbook 'FAST Setup PoC' completed successfully.\n📄 Session JSON saved to: logs/FAST_Setup_PoC_session.json\n📄 Markdown log saved to: logs/FAST_Setup_PoC_log.md\n```\n\nIf a step fails, the harness halts immediately and dumps the full interaction trace to a JSON file (e.g., `logs/FAST_Setup_PoC_failed.json`) for debugging.\n\n## Testing Local Skills (Inner Dev Loop)\n\nWhen developing a complex skill (with multiple markdown files, prompt templates, or tools), you don't want to package and globally install it just to run a test.\n\nThe harness supports testing local skills directly using the `--skill-src` flag:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml --skill-src ./my-local-skill/\n```\n\n**How it works under the hood:**\n\nThe harness passes the skill path to the SDK's `LocalAgentConfig(skills_paths=[...])`. The Antigravity engine loads the skill dynamically for the duration of the session. Unlike the old CLI-based linking, this is completely isolated and does not modify your global environment.\n\n## Workspace Management & Copying Behavior\n\nBy default, the test harness executes the agent in an isolated temporary workspace (e.g., `/tmp/gemini_harness_<hash>`) to prevent session caching and protect your repository from accidental file modifications.\n\n### Copying vs. Symlinking\nTo ensure both safety and compatibility with local search tools (such as the agent's built-in `search_directory` / grep tool, which can crash when traversing directory symlinks), **the harness copies the configured playbook directories recursively instead of symlinking them**.\n\nTo keep the workspace lightweight and prevent the agent from \"cheating\" by reading the test definitions, the copy operation implements strict exclusion rules:\n- **Excluded Dependencies/Cache**: `.terraform`, `.git`, `.venv`, `venv`, `__pycache__`, `.pytest_cache` are skipped. This reduces the copied size of directories like `fast/` from 1.4GB to a few megabytes, making workspace setup near-instant.\n- **Excluded Harness**: The `skill-turn-harness` directory itself is **strictly excluded** from the copy. This prevents the agent under test from walking the workspace, reading the playbook YAML definitions, and \"cheating\" by peeking at the expected inputs/outcomes.\n\n### Linking the `skills` Directory\nIf your autonomous playbook instructs the agent to \"activate\" or \"inspect\" the skill, the model may attempt to search the workspace for the skill's source files (like `SKILL.md`). For these playbooks, ensure you add `skills` to the playbook's `link_paths` so the agent can resolve the lookup locally:\n\n```yaml\ntmpdir:\n  link_paths:\n    - fast\n    - modules\n    - tools\n    - skills # <-- Make sure to include this so the agent can find skill files\n```\n\n### Isolated Chat History\nThe harness configures the SDK to write raw session state directly to the configured `--log-dir` (under `log_dir/chats/`). This ensures that test execution conversations remain isolated and do not pollute your global Antigravity/Jetski desktop history.\n\n## Writing Playbooks\n\nPlaybooks are written in YAML. For autocompletion and validation in VS Code, add the schema annotation to the top of your playbook.\n\nIf your playbook requires environment variables (e.g., secrets), declare them in the `env` array. You can then reference them in your `steps` using `${VAR_NAME}`. If a variable is declared but not found in the environment (or passed via `--env-file`), the harness will safely halt before execution.\n\n```yaml\n# yaml-language-server: $schema=../playbooks/playbook.schema.json\nname: \"My Test Playbook\"\ntimeout: 120\nagent_model: \"gemini-2.5-pro\"\nevaluator_model: \"gemini-2.5-flash\"\nenv:\n  - MY_API_KEY\nsteps:\n  - user_input: \"Hi, activate my-skill and use this key: ${MY_API_KEY}\"\n    expected_outcome: \"The agent should greet the user and acknowledge the key.\"\n```\n\n## Running the Pytest Suite\n\nThis repository includes a `pytest` suite in the `test/` directory to test the harness itself.\n\nTo run the fast unit tests (which mock the CLI execution):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"not e2e\" -v\n```\n\nTo run the full End-to-End (E2E) test (which dynamically links the fixture skill and hits the real Gemini API):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"e2e\" -v\n```\n\n## Writing Playbooks: Three Modes of Testing\n\nThe harness supports three modes of execution depending on how you structure your YAML playbook. The mode is inferred automatically based on the presence of the `steps` and/or `persona` keys.\n\n### 1. Scripted Mode (Unit / Regression)\n**Best for:** Ensuring the exact, rigid state machine of a skill hasn't broken.\n\nYou define a strict, sequential list of `steps`. The harness feeds the `user_input` and checks if the agent's response satisfies the `expected_outcome` via an LLM evaluation.\n\n```yaml\nname: \"FAST Setup PoC - Scripted\"\nsteps:\n  - user_input: \"Hi, let's configure FAST.\"\n    expected_outcome: \"The agent should greet the user and ask for the Project ID.\"\n  - user_input: \"my-super-project-123\"\n    expected_outcome: \"The agent should acknowledge the Project ID and ask for the preferred Region.\"\n```\n\n### 2. Autonomous \"Pond\" Mode (E2E / Fuzz Testing)\n**Best for:** Testing how the skill handles the messy reality of natural language and conversational drift.\n\nInstead of providing a rigid script, you define a declarative **Persona** with a \"Pond\" of knowledge and explicit `success_criteria`. A secondary LLM agent acts as the simulated user, dynamically reading the CLI's outputs, fishing data from the \"pond,\" and generating the next input until the success criteria are met or the `max_turns` limit is reached.\n\n```yaml\nname: \"FAST Setup PoC - Autonomous\"\npersona:\n  initial_user_input: \"Hi, let's configure FAST.\"\n  context: >\n    You are a GCP developer. Your Project ID is my-project-123 and region is europe-west1.\n    Do not volunteer information until the agent explicitly asks for it.\n  max_turns: 10\n  success_criteria:\n    llm_checks:\n      - \"The agent provided a final configuration summary containing the correct project_id and region.\"\n    tool_calls_contain:\n      run_shell_command:\n        - \"gcloud organizations add-iam-policy-binding\"\n    files_exist:\n      - \"0-org-setup.auto.tfvars\"\n```\n\n### 3. Hybrid Fallback Mode\n**Best for:** Testing happy-path compliance while ensuring the agent can recover from unexpected deviations.\n\nIf a playbook defines **both** `steps` and a `persona`, the harness runs in Hybrid mode. It attempts to execute the rigid `steps` first. If the skill deviates or fails a step evaluation, instead of failing the test outright, the harness **falls back** to the autonomous persona. The simulated user takes over the conversation history and attempts to guide the agent back on track to meet the `success_criteria`. If successful, the test returns a `PASS WITH WARNINGS`.\n","tools/skill-turn-harness/docs/DESIGN.md":"# Design Decisions: Test Harness Architecture\n\n## Context\n\nThis document captures architectural decisions and considerations for the `harness.py` test harness.\n\n## LangChain Integration Analysis\n\n*Date: April 15, 2026*\n\nWe evaluated whether to integrate LangChain into the `harness.py` script. The script currently acts as a lightweight testing harness that uses `subprocess` to interact with the Gemini CLI and the native `google.genai` SDK for evaluation using structured outputs (Pydantic).\n\n### Potential Benefits of LangChain\n\n1. **Model-Agnostic Evaluators (Avoiding Self-Bias):**\n   Currently, the harness uses Gemini 2.5 Flash to evaluate the Gemini CLI. To avoid \"self-preference bias\", it is often best practice to use a different model family for evaluation. LangChain's `ChatModel` abstractions would allow swapping the evaluator model easily without rewriting API call logic.\n2. **Built-in Evaluation Frameworks:**\n   LangChain provides a dedicated evaluation module (`langchain.evaluation`). Instead of custom prompts, we could leverage pre-built evaluators (like `CriteriaEvalChain`) that are prompt-engineered to reduce hallucinations and false positives.\n3. **Observability and Tracing (LangSmith):**\n   Integration provides seamless access to LangSmith for logging evaluation runs, inspecting prompts, latency, token usage, and tracking pass/fail rates over time.\n4. **Prompt Management:**\n   LangChain's `PromptTemplate` system offers robust handling for complex evaluation criteria (e.g., few-shot examples, dynamic context).\n\n### Drawbacks and Limitations\n\n1. **Overkill for Current Scope:**\n   The current script is lightweight and readable. LangChain is a heavy dependency that introduces complex abstractions (like LCEL/Runnables), adding bloat and a steeper learning curve.\n2. **Native Structured Outputs are Sufficient:**\n   The native `google.genai` SDK already handles structured JSON outputs via `response_schema=EvaluationResult` efficiently and reliably. LangChain's structured output would merely wrap this existing capability.\n3. **External Agent Execution:**\n   LangChain excels at managing agent memory, tools, and reasoning loops. Since our harness tests an external CLI tool via `subprocess.run`, LangChain cannot orchestrate the agent and is relegated strictly to the role of a grader.\n\n### Conclusion & Recommendation\n\n**Recommendation: Hold off on LangChain for now.**\n\nThe current architecture is elegant, dependency-light, and perfectly suited for its job. The native `google.genai` SDK handles the structured Pydantic evaluation flawlessly.\n\n**When to reconsider LangChain:**\n\n- We need to evaluate the CLI using non-Google models (e.g., Claude, GPT-4) to ensure unbiased grading.\n- We require visual tracking of test runs, prompt versions, and token costs using LangSmith.\n","tools/skill-turn-harness/docs/Hybrid Test Harness Architecture.md":"# **Architecture Document: Hybrid Python/CLI Test Harness**\n\nThis document outlines the architecture for testing the Fabric FAST configuration skill. It uses a hybrid approach, executing the skill in its native CLI environment while maintaining deterministic control via a Python orchestration loop.\n\n## **1\\. The Approach: Hybrid Isolation**\n\nTo accurately test the skill in its target environment while ensuring the reliability of the test harness, the execution and evaluation layers are strictly separated:\n\n* **Orchestrator (Python):** A Python script acts as the absolute authority. It maintains the state machine, reads the playbook, injects inputs, captures outputs, and triggers evaluations.  \n* **Execution Target (Gemini CLI):** The skill is run via the gemini CLI using Python's subprocess module. This ensures the test reflects the actual user environment. State is maintained across steps using the CLI's session management flags (e.g., \\--resume).  \n* **Evaluator (Gemini API):** The semantic evaluation of the CLI's output is performed via direct API calls to Gemini 1.5 Flash. This bypasses the string-parsing unreliability of a CLI and guarantees structured JSON output via Pydantic schemas.\n\n## **2\\. The Execution Loop**\n\nThe Python orchestrator executes the following rigid sequence for each step in a defined playbook:\n\n1. **Injection:** Read the mocked user input and expected outcome from the playbook step.  \n2. **Subprocess Execution:** Invoke the Gemini CLI with the user input and the designated session\\_id. Capture stdout and trap stderr to handle hangs or crashes.  \n3. **Prompt Assembly:** Construct a strict evaluation prompt combining the exact playbook expectation with the raw string response captured from the CLI.  \n4. **Stateless Evaluation:** Call the Gemini API with the evaluation prompt, enforcing a structured output schema (Boolean Pass/Fail and Reasoning).  \n5. **Verdict Enforcement:** If the evaluator returns True, proceed to the next step. If False, immediately halt the loop, dump the interaction trace to a JSON file, and alert the developer.\n\n## **3\\. Implementation Code**\n\nThe following Python script implements the hybrid harness:\n\nimport subprocess  \nimport json  \nimport sys  \nfrom pydantic import BaseModel  \nfrom google import genai  \nfrom google.genai import types\n\n\\# 1\\. Define Strict Evaluator Schema  \nclass EvaluationResult(BaseModel):  \n    passed: bool  \n    reasoning: str\n\nevaluator\\_client \\= genai.Client()\n\ndef invoke\\_skill\\_cli(user\\_input: str, session\\_id: str) \\-\\> str:  \n    \\# Requires the CLI to support a session resume flag for state  \n    command \\= \\[\"gemini\", \"--resume\", session\\_id, \"-p\", user\\_input\\]  \n    try:  \n        result \\= subprocess.run(command, capture\\_output=True, text=True, timeout=30)  \n        if result.returncode \\!= 0:  \n            print(f\"⚠️ \\[CLI ERROR\\]: {result.stderr}\", file=sys.stderr)  \n            return f\"SYSTEM\\_ERROR: {result.stderr}\"  \n        return result.stdout.strip()  \n    except subprocess.TimeoutExpired:  \n        print(\"⚠️ \\[CLI TIMEOUT\\]\", file=sys.stderr)  \n        return \"SYSTEM\\_ERROR: Timeout\"\n\ndef run\\_hybrid\\_tuning\\_loop(playbook\\_name: str, playbook\\_steps: list, session\\_id: str):  \n    print(f\"--- Tuning: {playbook\\_name} | Session: {session\\_id} \\---\")  \n    interaction\\_log \\= \\[\\]\n\n    for step\\_index, step in enumerate(playbook\\_steps):  \n        user\\_input, expected\\_outcome \\= step\\['user\\_input'\\], step\\['expected\\_outcome'\\]  \n          \n        skill\\_response \\= invoke\\_skill\\_cli(user\\_input, session\\_id)  \n        if skill\\_response.startswith(\"SYSTEM\\_ERROR\"): break  \n              \n        eval\\_prompt \\= f\"\"\"  \n        OBJECTIVE: {expected\\_outcome}  \n        ACTUAL RESPONSE: {skill\\_response}  \n        Evaluate if the agent fulfilled the objective.  \n        \"\"\"\n\n        eval\\_response \\= evaluator\\_client.models.generate\\_content(  \n            model=\"gemini-1.5-flash\", contents=eval\\_prompt,  \n            config=types.GenerateContentConfig(  \n                response\\_mime\\_type=\"application/json\",  \n                response\\_schema=EvaluationResult, temperature=0.0  \n            )  \n        )  \n          \n        parsed\\_eval \\= json.loads(eval\\_response.text)  \n        interaction\\_log.append({\"step\": step\\_index \\+ 1, \"input\": user\\_input, \"evaluation\": parsed\\_eval})\n\n        if not parsed\\_eval\\['passed'\\]:  \n            print(f\"❌ \\[FAILURE Step {step\\_index \\+ 1}\\]: {parsed\\_eval\\['reasoning'\\]}\")  \n            with open(f\"{playbook\\_name}\\_failed.json\", \"w\") as f: json.dump(interaction\\_log, f)  \n            return False \n\n    print(f\"✅ \\[SUCCESS\\]\")  \n    return True\n\n## **4\\. Critical Implementation Warnings**\n\n* **Session Data Persistence:** The CLI likely persists session states to disk (e.g., in a local database or JSON file). If you reuse the same session\\_id for consecutive test runs without manually deleting the cache file, the skill will inherit the context of the previous run, causing immediate test failures. You must either generate a UUID for every run or build a cache-clearing mechanism into the Python script.  \n* **Context Window Discipline:** The evaluation prompt is strictly limited to the current playbook objective and the immediate CLI response. Do not feed the entire CLI conversation history to the Evaluator API, as this significantly increases the risk of hallucinated grading."},"files":{"AGENTS.md":"GEMINI.md","skills/README.md":"# Fabric Skills\n\nThis directory contains skills for AI agents to leverage Cloud Foundation Fabric (CFF). These skills provide context, guidelines, and tools for agents to perform specific tasks related to CFF.\n\n## Installing with `agents-cli`\n\nYou can install the user-facing Fabric skills (`fabric-builder`, `fast-networking`, and `fast-prerequisites`) into your project's `.agents/skills/` directory (or globally into `~/.agents/skills/` with `--global`) using [`agents-cli`](https://google.github.io/agents-cli/guide/extensions/):\n\n```bash\nagents-cli extension add GoogleCloudPlatform/cloud-foundation-fabric\n```\n\n## Available Skills\n\n- **[contributing](./contributing)**: End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\n- **[fabric-builder](./fabric-builder)**: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n- **[fabric-importer](./fabric-importer)**: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n- **[fast/networking](./fast/networking)**: Designs, customizes, and validates FAST `2-networking` YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a `2-networking` dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a `2-networking` dataset.\n- **[fast/prerequisites](./fast/prerequisites)**: Guides the user step-by-step through the prerequisites for the FAST `0-org-setup` stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for `0-org-setup` or bootstrap the FAST landing zone.\n- **[maintenance/release-process](./maintenance/release-process)**: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n","skills/contributing/SKILL.md":"---\nname: contributing\ndescription: \"End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\"\n---\n\n# Fabric Contribution Flow Skill\n\nThis skill defines the end-to-end workflow for contributing to Cloud Foundation Fabric. It supports two entry modes:\n- **Mode A: Issue Triage & Bug Fix**: You are addressing an assigned or reported GitHub Issue. Start at **Step 1**.\n- **Mode B: Proactive Development & PR Prep**: You are actively building a feature, refactoring code, or preparing an existing branch for a Pull Request. Jump directly to the **Design Sign-off Gate** before Step 2.\n\nIf it is unclear which mode applies, ask the user before proceeding.\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where your judgment is likely to diverge from a maintainer's. Keep mechanical, reversible steps (linting, tests, doc and inventory regeneration) autonomous. Gates are **blocking**: if you are running non-interactively and cannot get an answer, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Triage Disposition** | End of Step 1 (Mode A) | Whether the issue is in scope and worth pursuing: proceed, reject, or escalate. |\n| **Design Sign-off** | Before Step 2 | Approves the proposed design and scope. Mandatory for new features and interface changes; skippable for trivial fixes where the design is self-evident. |\n| **E2E Opt-in** | Step 5 | Whether to run live cloud verification. Providing a sandbox project ID **is** the consent to deploy to it. |\n| **Behavioral Verification Opt-in** | Step 5, after read-back verification | Whether to also verify runtime behavior of the deployed resources, given the described extra time and cost. |\n| **PR Approval** | Step 6 | Reviews the final sanitized PR body before submission. |\n\n---\n\n## Step-by-Step Workflow\n\n```mermaid\ngraph TD\n    M1[Mode A: Issue Triage] --> A[1. Triage Issue]\n    A --> GT{Gate: Triage Disposition}\n    GT -->|Human approves| GD{Gate: Design Sign-off}\n    GT -->|Human rejects| X[Stop / Report Back]\n    M2[Mode B: Proactive Dev] --> GD\n    GD --> B[2. Develop Fix / Feature]\n    B --> C[3. Tests, Lint & Inventories]\n    C --> D[4. Pre-Submission Self-Review]\n    D -->|Issues found| B\n    D -->|Clean| GE{Gate: E2E Opt-in}\n    GE -->|Project ID provided| G[5. Live E2E Verification]\n    GE -->|Skipped| GS{Gate: PR Approval}\n    G -->|Failures| B\n    G -->|Verified| GS\n    GS --> F[6. Commit & Submit Sanitized PR]\n```\n\n### Step 1: Triage the Issue (Mode A Only)\n\n1.  **Retrieve Issue Details**: Use the GitHub CLI to view the issue context.\n    ```bash\n    gh issue view <issue-number>\n    ```\n\n2.  **Explore the Codebase**: Identify the target module (`modules/<module-name>`) or FAST stage (`fast/stages/<stage-name>`) that requires modification.\n\n3.  **Evaluate Fit & Scope**: Assess whether the issue is relevant for Fabric. Ensure it aligns with Fabric's core philosophy (modules should be lean, composable, and represent a single resource type context). Confirm the change has a sufficiently large scope and represents a generic, valuable addition to the module or FAST stage rather than a highly specific, one-off customization.\n\n4.  **Read Provider Documentation**: If the issue involves Google Cloud resources, retrieve and read the documentation for the involved GCP resources or Terraform provider resource/datasource to ensure accurate implementation of its attributes, behaviors, and constraints.\n    *   Start from the provider registry, e.g. <https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_address>.\n    *   **Check the pinned provider range**: the repo pins the `google` provider in [default-versions.tf](../../default-versions.tf). If the feature you are implementing requires provider functionality released after the pinned minimum version, that is a good reason to bump the pin — do it across the board with `uv run tools/versions.py --provider-min-version <x.y.z> --write-defaults`, which rewrites `default-versions.tf` and propagates it to every module and stage `versions.tf` (linting enforces they stay in sync). Never edit version pins in individual modules only.\n    *   Only rely on documentation for functionality available in a **released** provider version: docs from the provider's `main` branch may describe unreleased attributes that no released provider accepts at plan time.\n    > [!WARNING]\n    > Do NOT rely solely on the proposed solution, examples, or partial specifications provided in the issue description. Always retrieve and review the complete documentation schema from the official provider registry.\n    >\n    > **Workaround for Registry Page JS/Redirection Errors**:\n    > If the registry page fails to load properly with your URL-fetching tool (e.g. returns \"Please enable Javascript\" or truncates due to HTML formatting issues), find the source markdown file on GitHub and fetch its raw content using `curl` to a scratch file (the `scratch/` directory is gitignored), using the tag of the provider release whose docs you need (e.g. `v7.40.0`):\n    > ```bash\n    > curl -s https://raw.githubusercontent.com/hashicorp/terraform-provider-google/v7.40.0/website/docs/r/workstations_workstation_config.html.markdown -o scratch/workstation_config_doc.md\n    > ```\n    > You can then search and view it locally to identify all supported arguments and blocks.\n    > Use this information to make an informed decision on which arguments to support. While 100% coverage is not always necessary or desirable, make a deliberate choice to include all arguments that are useful and align with Fabric's composability and simplicity goals, rather than missing them by omission.\n\n5.  **Gate — Triage Disposition**: Present your assessment (target module/stage, fit with Fabric's philosophy, scope evaluation) and let the human decide whether to proceed. If the issue fails the fit test, report your reasoning and stop — do NOT comment on, label, or close the issue yourself; disposition of the issue is a maintainer decision.\n\n### Gate: Design Sign-off (Modes A & B)\n\nBefore writing code, present the proposed design for approval:\n*   The target module(s)/stage(s) and the shape of the variable interface (new/changed variables, whether `context` support is added).\n*   For provider-surface changes: the explicit list of provider arguments you intend to include and exclude, with reasoning.\n*   Whether the change requires bumping the pinned provider version (a repo-wide change — see Step 1.4).\n\nThis gate is **mandatory for new features and interface changes**. For trivial fixes (typos, one-line bugfixes with an obvious solution) where the design is self-evident, state your intent and proceed without blocking.\n\n### Step 2: Develop the Fix or Feature (Modes A & B)\n\n1.  **Align with Fabric Zen**: Ensure the design focuses on composition, encapsulates logical entities (like IAM and log sinks directly inside modules), adopts common interfaces, and keeps code flat and easy to evolve.\n    > [!IMPORTANT]\n    > You MUST strictly follow the design principles and coding conventions defined in [CONTRIBUTING.md](../../CONTRIBUTING.md) and [GEMINI.md](../../GEMINI.md) when designing variables, modules, and factories.\n\n2.  **Set Up the Git Branch**:\n    *   If you are not already on a feature branch, create one named after your username and the feature/fix (e.g., `<username>/<feature-name>`, such as `johndoe/feature-name`):\n        ```bash\n        git checkout master && git pull\n        git checkout -b <username>/<feature-name>\n        ```\n    *   If preparing an existing branch (Mode B), do NOT create a new one; instead make sure it is up to date with `master` (rebase or merge) before submission.\n    *   Direct branches require push access to the repo (granted to regular contributors on request). Without it, use the fork-based flow described in [CONTRIBUTING.md](../../CONTRIBUTING.md); the rest of this workflow is identical.\n\n3.  **Apply Fabric/FAST Design Conventions**:\n    *   **Context Interpolation**: If context support is relevant and needed for the module (e.g., to support resolving symbolic references like `\"$project_ids:myprj\"`), add a `context` variable block and implement `ctx`/`ctx_p` locals in `main.tf`. Do not add it blindly if the module does not benefit from symbolic interpolation.\n    *   **Compact Variables**: Leverage objects with `optional()` attributes to keep user interfaces clean. Prefer `optional()` **without** an explicit default so the provider applies whatever default value it defines; only set a default when module logic needs a known value.\n    *   **Stable State Keys**: Always use maps instead of lists for collection variables to avoid index shifts in Terraform state.\n    *   **Scope Isolation**: Use private locals (prefixed with `_`) for intermediate transformations, reserving module-level locals for values referenced by resources.\n    *   **Locals Separation for Complex Manipulations**: When adding complex conditional strings, string interpolations, or transformations to resource attributes, compute the map in `locals` so the resource block references a clean index like `local.my_map[each.key]`. This keeps resource blocks legible (<79 chars) and avoids cluttering resource definitions.\n    *   **Validate ENUM Variables**: When adding or exposing variables that mirror ENUM values in the underlying Terraform provider, always include a `validation` block to check for allowed values at plan time. Example from `modules/gcs`:\n        ```hcl\n        variable \"storage_class\" {\n          description = \"Bucket storage class.\"\n          type        = string\n          default     = \"STANDARD\"\n          validation {\n            condition     = contains([\"STANDARD\", \"MULTI_REGIONAL\", \"REGIONAL\", \"NEARLINE\", \"COLDLINE\", \"ARCHIVE\"], var.storage_class)\n            error_message = \"Storage class must be one of STANDARD, MULTI_REGIONAL, REGIONAL, NEARLINE, COLDLINE, ARCHIVE.\"\n          }\n        }\n        ```\n\n4.  **Reference Implementations & Knowledge Sources**:\n    *   [compute-vm](../../modules/compute-vm): Ideal reference for compact variable design, optional objects with defaults, and map variables.\n    *   [project](../../modules/project): Best reference for logical entity encapsulation (IAM, log sinks, Shared VPC configurations), complex locals transformation, and context-based interpolation.\n    *   [adrs/](../../adrs/): Architectural Decision Records documenting the rationale behind key design patterns (e.g. the IAM ADR explains the full `iam*` interface). Before designing, build an index of all ADRs with their title and status:\n        ```bash\n        find adrs -name '*.md' ! -name 'README.md' | sort | while read -r f; do\n          st=$(awk '/^## Status/ {getline; while ($0 == \"\") getline; print; exit}' \"$f\")\n          printf '%s | %s | %s\\n' \"$f\" \"$(head -1 \"$f\" | sed 's/^# //')\" \"$st\"\n        done\n        ```\n        Read in full any ADR whose title relates to the area you are changing. Weigh them by status: ignore `Rejected` ones, treat `Draft`/`Proposed` as directional rather than settled, and remember that even implemented ADRs can lag behind the code — on conflict, the current code and golden-path modules are the source of truth.\n\n5.  **Maintain Code Consistency**:\n    *   Always keep variables in `variables.tf` and outputs in `outputs.tf` in strict alphabetical order. For larger modules, variables and outputs can be grouped logically into separate files (e.g. `variables-logging.tf`), each internally alphabetically sorted.\n    *   Limit line length to 79 characters (relaxed for long attribute names and descriptions).\n    *   **JSON Schema & Factory Alignment**: When a change is made to a module that implements a factory (e.g., `cloud-workstations`) or is the base for one (e.g., `net-vpc-factory` to `2-networking`), the schemas MUST be updated (including their `.schema.md` documentation files), and the factory code should be updated to mirror the changed variable surface. Ensure you regenerate the schema documentation by running:\n        ```bash\n        uv run tools/schema_docs.py\n        ```\n    *   **Duplicated Files**: Some files (e.g. schemas, factory test fixtures) are intentionally duplicated across modules and stages, and CI enforces that copies stay identical. When changing such a file, sync all its copies; the duplicate groups are listed in [tools/duplicate-diff.py](../../tools/duplicate-diff.py) — update that list when adding new duplicated files.\n\n6.  **Update or Add Tests**:\n    *   For modules: If the change introduces a new feature or configuration option, update an existing example in the module's `README.md` (or add a new one) to demonstrate its usage. Ensure the example includes `# tftest` parameters and update the corresponding inventory YAML files under `tests/modules/<module_name>/examples/`.\n    *   For FAST stages: Update `tftest.yaml` scenarios and tfvars/yaml inventories under `tests/fast/...`.\n    > [!NOTE]\n    > Module directories use hyphens (`modules/net-vpn-ha`) while test directories generally use underscores (`tests/modules/net_vpn_ha`). Always check the actual test directory name — a mistyped path makes `pytest` silently collect zero tests.\n\n### Step 3: Run Tests, Linting & Inventory Regeneration (Modes A & B)\n\nRun Python tooling through `uv run` — the scripts in `tools/` declare their dependencies inline, so no virtual environment setup is needed. For faster Terraform testing, always set `TF_PLUGIN_CACHE_DIR=/tmp/tfcache`.\n\n1.  **Run Unified Linting**: Execute `tools/lint.sh` to check copyright boilerplates, Terraform format (`terraform fmt`), alphabetical sorting, and schema validations:\n    ```bash\n    uv run --with-requirements tools/requirements.txt tools/lint.sh\n    ```\n\n2.  **Update Documentation**: If you changed variables or outputs, check consistency and regenerate the README documentation tables:\n    ```bash\n    uv run tools/check_documentation.py modules/<module-name>\n    uv run tools/tfdoc.py --replace modules/<module-name>\n    ```\n\n3.  **Run Impacted Tests**: Execute `pytest` on the target module/stage (using the plugin cache directory for speed):\n    ```bash\n    mkdir -p /tmp/tfcache\n    # tftest.yaml scenarios (note: underscores in the test directory name)\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest tests/modules/<module_name>\n    # README example tests\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest -k 'modules and <module-name>:' tests/examples\n    ```\n\n4.  **Regenerate Test Inventories**: If module-level tests (`tftest.yaml`) or README example inventories fail due to intentional plan output changes, regenerate them using `generate_plan_summary.py`:\n    ```bash\n    # For module-level tftest.yaml inventories:\n    uv run tools/generate_plan_summary.py tests/modules/<module_name>/tftest.yaml <test-name> --save\n\n    # For README.md example inventories:\n    uv run tools/generate_plan_summary.py modules/<module-name>/README.md \"<Example Heading>\" --save\n    ```\n    > [!CAUTION]\n    > `--save` overwrites the assertion baseline, so a regenerated inventory makes the test pass **by construction**. After regenerating, diff the inventory against the previous version (`git diff tests/`), verify that every changed line maps to the intended feature or fix, and report the inventory diff to the user. If a changed line is not explained by your change, treat it as a bug in your code, not a baseline to save.\n\n### Step 4: Pre-Submission Self-Review (Modes A & B)\n\nReview your own diff (`git diff HEAD`, or `git diff master...HEAD` for an existing branch) against the repository guidelines (`GEMINI.md`, `CONTRIBUTING.md`) before submission. This is a pre-flight self-check; the canonical automated review runs in CI after the PR is opened (`Automated PR Review` workflow) — do NOT imitate its output format or header.\n\nCheck the diff against this checklist:\n\n*   [ ] Naming conventions respected (variables, outputs, resources).\n*   [ ] Context support (`ctx` variables) present where the module warrants it.\n*   [ ] IAM follows the standard `iam*` interface patterns.\n*   [ ] Examples with `# tftest` parameters cover the new/changed behavior.\n*   [ ] **CRITICAL TESTING RULE**: if resource blocks were modified (adding a new argument or modifying an existing one), the Terraform plan output changes — the corresponding test inventory YAML files (`tests/.../*.yaml`) MUST be updated in the diff. If they are not, this is a critical testing failure.\n*   [ ] JSON schemas and factory code consistent with the changed variable surface.\n*   [ ] Documentation tables regenerated (`tfdoc`), alphabetical order preserved.\n*   [ ] **Breaking Changes**: If any variable or YAML interface changed in a backwards-incompatible way, verify the PR description draft includes the `**Breaking Changes**` / `upgrade-note` block.\n\nReport the outcome as a short plain-text list of issues found (or \"no issues found\") under a `Pre-submission self-review` heading — no emojis, no status tables. Fix any issue and loop back to Step 3 until the checklist passes.\n\n### Step 5: Live Verification — E2E Sandbox & Policy Troubleshooter (Modes A & B — Optional / Recommended)\n\nWhen code modifications affect GCP resource structures or APIs, run a live E2E sandbox deployment test. Run it only once the diff is stable (tests and self-review pass) to avoid repeated cloud deployments.\n\n1.  **Gate — E2E Opt-in**: Ask the user for a GCP project ID (and if applicable, parent folder / billing account details) for E2E sandbox testing, stating explicitly that providing the project ID authorizes you to deploy and destroy resources in it with `-auto-approve`. If no project is provided, skip this step entirely.\n\n2.  **Verify Credentials**: Confirm `gcloud` authentication and Application Default Credentials are available (`gcloud auth list`) before attempting any deployment, and surface a clear error rather than failing mid-apply.\n\n3.  **Create Sandbox Directory**: Create a temporary sandbox folder under `scratch/e2e_sandbox/` (the `scratch/` directory is gitignored).\n\n4.  **Generate Test Configuration**:\n    *   Generate a root Terraform module `main.tf` in the sandbox directory.\n    *   **CRITICAL**: The `source` argument of the module call MUST point to the **local path** of the modified module in the repository (e.g. `source = \"../../modules/<module-name>\"`), NOT the GitHub reference, to ensure your local changes are tested.\n    *   Set up necessary providers and variables.\n\n5.  **Deploy**:\n    *   Run `terraform init` and `terraform apply -auto-approve` in the sandbox folder.\n    *   Confirm that all resources are created successfully. A clean apply is the baseline, not the goal: it only proves the API accepted the request, not that the change behaves as intended.\n\n6.  **Read-Back Verification (required)**:\n    *   Verify the deployed state through the service's read API (e.g. `gcloud compute backend-services describe`, or the equivalent `GET`/`describe` surface for the resource), not just the Terraform state or plan output.\n    *   For every field or block touched by the change, confirm the live resource contains the intended value and structure (e.g. one API list entry per input element, correct nesting, no silently dropped attributes).\n\n7.  **Behavioral Verification (optional — Gate)**:\n    *   **Gate — Behavioral Verification Opt-in**: after read-back verification passes, explicitly ask the user whether to also verify runtime behavior, describing what the check would entail for this specific change (resources involved, expected extra time such as propagation delays, any additional cost). Proceed only on explicit approval; skipping is a valid outcome and must be recorded in the PR body.\n    *   Verify the change behaves as expected from the perspective of a consumer of the deployed infrastructure. Examples:\n        *   Compute instances: connect to the instance (SSH/serial) and check it is reachable and its startup configuration behaves as expected.\n        *   Networking, load balancers, DNS: send traffic through the deployed path and verify routing, reachability, and response attributes (e.g. `curl` through a load balancer and inspect status and headers, resolve records, test connectivity across peerings or VPN tunnels).\n        *   Serverless and workloads (Cloud Run, Cloud Functions, GKE): invoke the deployed service and verify the response.\n        *   Data services (GCS, BigQuery, Pub/Sub): perform a representative read/write operation (upload an object, run a query, publish and pull a message).\n    *   Account for propagation delays (load balancers, CDN, IAM can take several minutes); retry before concluding failure.\n    *   **IAM conditional bindings**: use Google Cloud's official IAM Policy Troubleshooter as the behavioral check (`gcloud policy-troubleshoot iam`) to verify runtime evaluation against live target resources.\n    *   **CRITICAL**: GCP IAM runtime evaluates `resource.name` using numeric Project Numbers. Always test conditions against `--resource-name` with the numeric Project Number:\n        ```bash\n        gcloud policy-troubleshoot iam //logging.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --permission=logging.buckets.write \\\n          --principal-email=<TEST_PRINCIPAL_EMAIL> \\\n          --resource-name=projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --format=\"json(access,explainedPolicies[].bindingExplanations)\"\n        ```\n\n8.  **Destroy Resources**:\n    *   Once verified, run `terraform destroy -auto-approve` to tear down all created resources and avoid ongoing cloud costs.\n    *   Delete the sandbox directory.\n    *   If verification surfaced failures, fix the code and loop back to Step 3 before re-deploying.\n\n### Step 6: Commit & Submit the PR (Modes A & B)\n\n1.  **Sanitize Before Committing**: PII sanitization applies to **everything that leaves your machine** — commits, file contents, and the PR body. Never commit files containing real GCP project IDs, numeric project numbers, personal email addresses, or custom resource names from live testing; verify `git status` shows no stray sandbox or scratch files before staging.\n\n2.  **Commit and Push**: Stage the relevant files, commit with a short imperative message describing the change, make sure the branch is up to date with `master` (rebase if needed), and push:\n    ```bash\n    git add <files>\n    git commit -m \"Add <feature> to modules/<module-name>\"\n    git push -u origin <username>/<feature-name>\n    ```\n\n3.  **Format the PR Title**: Do NOT use Conventional Commits format (no `feat:` or `fix:` prefixes). Use a short, capitalized, imperative title (e.g., \"Add native tag bindings support to `modules/net-firewall-policy`\").\n\n4.  **Write the PR Body**:\n    *   Explain the problem, rationale, and the fix clearly.\n    *   **Document Verification & E2E Testing Methodology**: Detail local unit tests and, for E2E runs, the apply, read-back, and behavioral verification results (or note that behavioral verification was skipped) so reviewers can see the exact testing rationale and methodology.\n    *   **CRITICAL PII SANITIZATION**: Before writing the PR description, **MUST scrub all developer PII** (real GCP project IDs, numeric project numbers, personal email addresses, usernames, and custom bucket/resource names) and replace them with generic placeholders (e.g., `my-project`, `123456789012`, `user:tester@example.com`, `test-bucket`).\n    *   **Breaking Changes & Upgrade Notes**: Whenever a change requires users to upgrade their variable or YAML surface (e.g. variable type changes, deleted attributes, changed schema structures), you **MUST append** a `**Breaking Changes**` section with an `upgrade-note` code fence at the bottom of the PR description. This is automatically parsed by `tools/changelog.py` when generating release notes.\n        Format:\n        ````markdown\n        **Breaking Changes**\n\n        ```upgrade-note\n        `modules/organization`: `custom_roles` variable type changed from `map(list(string))` to `map(object({...}))`. Existing code passing permission lists directly must be updated to pass `{ permissions = [...] }`.\n        ```\n        ````\n    *   Do NOT touch `CHANGELOG.md`: release notes are generated from PR labels and `upgrade-note` blocks by maintainers using `tools/changelog.py`.\n\n5.  **Gate — PR Approval**: Present the final sanitized PR title and body to the user and get explicit approval before creating the PR.\n\n6.  **Create the PR**:\n    *   **CRITICAL PITFALL**: Do NOT pass the body inline on the CLI if it contains backticks (e.g. `gh pr create --body \"Fixes `bug`\"`) as the shell will evaluate backticks, corrupting the description. Do NOT use shell redirection or heredocs to create the body file either (see `GEMINI.md`).\n    *   Instead, write the body to a temporary file **using your file-writing tool**, then reference it:\n        ```bash\n        gh pr create --title \"Your PR Title\" --body-file /tmp/pr-body.md\n        ```\n","skills/fabric-builder/SKILL.md":"---\nname: fabric-builder\ndescription: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n---\n\n# Fabric Builder\n\nThis skill generates idiomatic Terraform code using Cloud Foundation Fabric (CFF) modules, following established best practices and conventions.\n\n## Core Workflow\n\n1. **Understand Request:** Identify the GCP resources and relationships requested by the user.\n2. **Fetch Module Info:** Identify the relevant CFF module(s) from the `modules/` folder on GitHub (`GoogleCloudPlatform/cloud-foundation-fabric`).\n3. **Generate Terraform:** Output an idiomatic Terraform root module that consumes the CFF modules according to [conventions.md](references/conventions.md).\n\n## Important Guidance\n\n- **Conventions & Best Practices:** Consult [conventions.md](references/conventions.md) for guidelines on how to consume Fabric modules and write high-quality, idiomatic Terraform code (including module sources, version constraints, and provider configuration).\n- **Fetching Modules:** Do not invent module inputs or outputs. Use the `fabric.py` script to pull exact details (README, variables, examples) for a specific module from GitHub before using it.\n  - To list available modules: `python3 scripts/fabric.py modules`\n  - To fetch details for a module (README): `python3 scripts/fabric.py fetch readme <module_name>`\n  - To fetch variables files: `python3 scripts/fabric.py fetch variables <module_name>`\n  - To fetch outputs files: `python3 scripts/fabric.py fetch outputs <module_name>`\n  - To fetch schema files (useful for factories): `python3 scripts/fabric.py fetch schemas <module_name>`\n  - To fetch the latest release version: `python3 scripts/fabric.py release`\n  - **SSL Certification Verification**: If you run into SSL certificate issues on your host machine, use the `--no-ssl-verify` flag (e.g., `python3 scripts/fabric.py --no-ssl-verify fetch readme <module_name>`).\n\n## Guidelines for Output\n\n- **Root Module Output:** Your output must be a complete Terraform root module that calls the appropriate CFF modules to fulfill the user's requirements.\n- **Follow Conventions:** Strictly adhere to [conventions.md](references/conventions.md) for module sources, version constraints, provider configuration, and coding style.\n- **Use Fabric Modules:** Rely on CFF modules instead of raw `google_` resources whenever possible.\n- **Example-based Learning:** Always refer to the module's README (fetched via `scripts/fabric.py`) for correct usage examples.\n- **Formatting & Validation:** Ensure the generated code is properly formatted. If possible, run `terraform fmt`, `terraform validate`, and `terraform plan` to check your work.\n","skills/fabric-builder/references/conventions.md":"# Cloud Foundation Fabric (CFF) Conventions for Module Consumers\n\nWhen generating Terraform code that consumes Cloud Foundation Fabric modules, you MUST adhere to the following conventions:\n\n## 1. Module Preference\n- **Prefer Modules:** Always prefer CFF modules over raw `google_*` resources.\n- **Flat Structure:** Avoid creating wrapper modules or nested module calls (modules calling other modules). Consume CFF modules directly in your root module.\n- **Module Sources:** Ask the user whether they want to use relative local paths or absolute paths referencing the GitHub repository.\n  - Relative local paths: `source = \"./modules/project\"`\n  - GitHub repository paths: `source = \"github.com/GoogleCloudPlatform/cloud-foundation-fabric//modules/project?ref={VERSION}&depth=1\"` (include version ref and `depth=1`).\n  - All modules used must specify the same version tag. By default, unless the user states otherwise, default to using the latest release tag (obtainable via `python3 scripts/fabric.py release`).\n\n## 2. Naming Conventions\n- **Use `prefix`:** For modules that support it (e.g., `project`, `gcs`), using a `prefix` variable is recommended but not mandatory.\n- **Deterministic Naming:** Prefer using structured, deterministic tokens rather than random strings.\n\n## 3. Dependency Management\n- **Output-to-Input:** Pass outputs from one module directly as inputs to another (e.g., `network = module.vpc.name`).\n- **Ordering:** CFF modules encapsulate dependencies (like API activation) in their outputs. Rely on these outputs to ensure correct creation order.\n- **Service Agents:** Use the `service_agents` output from the `project` module when granting IAM roles to Google-managed service accounts.\n- **Implicit Dependencies:** Avoid explicit `depends_on` unless absolutely necessary. Rely on implicit dependencies (passing outputs to inputs) for better readability and maintainability.\n\n## 4. Factories\n- **When to Use Factories:** Use factories when you need to manage a large number of similar resources (e.g., projects, VPCs, firewall rules) without duplicating module blocks. Factories separate configuration data (in YAML files) from Terraform logic.\n- **Main Modules with Factory Support:**\n  - `project-factory`: For bulk creation of projects, folders, and budgets.\n  - `net-vpc-factory`: For bulk creation of VPCs and associated resources.\n  - `net-vpc`: Supports loading subnets and internal ranges from folders via `subnets_folder` and `internal_ranges_folder` keys in `factories_config`.\n  - `net-vpc-firewall`: Supports loading firewall rules from a folder via `rules_folder`.\n  - `organization` and `folder`: Support loading organization policies and custom roles.\n  - `vpc-sc`: Supports loading access levels and service perimeters.\n- **Usage:** Pass the path to the directory containing YAML files to the `factories_config` variable of the respective module.\n\n## 5. Style for Root Modules\n- **File Structure:** Make file structure dependent on size. For small configurations, use a single `main.tf`. For larger configurations, split into multiple files grouped by resource type (e.g., `main.tf` for general elements, `networking.tf`, `compute.tf`, etc.).\n- **Variables & Defaults:**\n  - Define all variables in `variables.tf`, sorted alphabetically.\n  - Set defaults directly in the `default` attribute if a reasonable default exists or if provided by the user.\n  - Avoid creating a `terraform.tfvars` file unless explicitly requested by the user.\n- **Value Handling & Providers:**\n  - **No Hardcoded Values:** Never use hardcoded values for project IDs, folder IDs, or other specific identifiers unless provided by the user. If a value is required, ask the user for it or create a variable.\n  - **Provider Attributes:** Do not configure provider-level attributes like `project`, `region`, or `zone` in provider blocks (`provider \"google\"`, `provider \"google-beta\"`). All attributes must be passed to the modules explicitly in the module calls.\n- **Formatting:** Adhere to standard Terraform formatting and keep line lengths readable. Wrap complex ternaries in parentheses.\n- **No Local Exec:** Never use `local-exec` or similar provisioners to run shell commands. Rely on native Terraform resources and data sources, preferably from official providers (i.e., no third-party providers).\n\n## 5a. Version Constraints\n- **Omit Root Version Constraints:** Do not include version constraints (for Terraform or providers in `required_providers` or `required_version`) in the root module.\n- **Automatic Resolution:** Let Terraform automatically compute and resolve the required provider and Terraform versions based on the constraints defined within the consumed CFF modules.\n\n## 6. Impersonation and Backend Management\n- **Impersonation:** If the user requires service account impersonation, add the `impersonate_service_account` attribute to the `provider \"google\"` and `provider \"google-beta\"` blocks in `providers.tf`.\n- **Remote Backend:** If the user wants to use a remote backend, prefer `gcs`. Put the backend configuration in the `terraform` block inside `providers.tf`. If impersonation is used, also set it for the backend.\n\n**Template for `providers.tf`:**\n```terraform\nterraform {\n  backend \"gcs\" {\n    bucket                      = \"${bucket}\"\n    impersonate_service_account = \"${service_account}\"\n  }\n}\n\nprovider \"google\" {\n  impersonate_service_account = \"${service_account}\"\n}\n\nprovider \"google-beta\" {\n  impersonate_service_account = \"${service_account}\"\n}\n```\n","skills/fabric-importer/COVERAGE.md":"# Import maturity matrix\n\nMaturity ladder — every resource family is at exactly one level:\n\n| Level | Meaning | Exit criterion |\n|---|---|---|\n| **N** — named | Appears in an example manifest; no mapping rules yet | — |\n| **C** — cookbook | Mapping rules + import IDs + module addresses derived from module source; not yet plan-tested live | cookbook section exists |\n| **V** — verified | Clean imports, both gates green, address table confirmed live against real Google Cloud resources | verified across live rounds |\n\nVerification target: everything in the four FAST-stage example\nmanifests at **V**. Maturity decays — every V carries the provider and\nFabric ref it was verified against; re-verify on bumps (see\n\"Regression habit\").\n\n## Matrix\n\n| Family | Module | Level | Status / Notes |\n|---|---|---|---|\n| Org IAM + audit configs | organization | **V** | Seeded audit configs, `${` escaping verified |\n| Org policies (incl. dry-run, parameters, custom constraints) | organization | **V** | Three-stream denominator enumeration verified |\n| Custom roles | organization | **V** | From-scratch and modified roles verified |\n| Org log sinks | organization | **V** | Destinations graduated to typed module references |\n| Folders + folder IAM | folder (per instance) | **V** | Parent chaining and in-place rename verified |\n| Projects, services, project IAM | project (per instance) | **V** | Project services and IAM bindings verified |\n| Service accounts + impersonation IAM | iam-service-account | **V** | Leaf-IAM opt-in verified |\n| VPC networks / subnets / routes | net-vpc | **V** | ForceNew alignment and subnet address mapping verified |\n| Log buckets (sink destinations) | logging-bucket | **V** | Reference graduation verified |\n| GCS buckets | gcs | **V** | State buckets, ForceNew project ID prefix verified |\n| BigQuery datasets | bigquery-dataset | **V** | Datasets, import IDs, `description = null` alignment verified (tables/views/routines unverified) |\n| Pub/Sub topics | pubsub | **V** | Topics, import IDs, sink reference graduation verified (subscriptions/schemas unverified) |\n| DNS zones | dns | **V** | Private zones, VPC reference link verified |\n| DNS policies / response policies | dns | **N** | Named in `examples/import-manifest.fast-networking.yaml`; no mapping rules yet |\n| NCC spokes (linked-VPC) | raw resource — **capability gap**: no Fabric module (`ncc-spoke-ra` is router-appliance only) | **V** (raw) | Upstream-issue candidate, lift with `moved {}` when a module appears |\n| Cloud Routers / NAT | net-cloudnat | **V** | Integrated router + NAT verified |\n| Firewall policies | net-firewall-policy | **C** | Mapping rules documented in cookbook (Hierarchical, Global, and Regional Network policies) |\n| VPN / Interconnect attachments | net-vpn-*, net-vlan-attachment | **C** | Mapping rules documented in cookbook |\n| KMS keyrings / keys + key IAM | kms | **V** | Seeded keyring, key, leaf-IAM on CryptoKey verified; permanent residue documented |\n| Certificate Authority Service | certificate-authority-service | **V** | CA pools: CAI type, import ID, pool address (`google_privateca_ca_pool.default[0]`), `ca_configs = {}` default-CA trap, and `publishing_options` support verified on Fabric ref containing PR #4107 (commit `a153861aae`, released in `v58.0.0`). Note: on releases lacking #4107 (`v57.0.0` and earlier), CAS reverts to capability gap |\n| VPC-SC policy / perimeters | vpc-sc | **V** | Live AccessPolicy adopted, AccessLevel, dry-run ServicePerimeter; bare-numeric policy import ID verified; `unknown`-level classification handled in `inventory.py`. Enforced perimeters (`status` block) unverified |\n| Tags (keys/values/bindings) | organization / folder / project tags | **V** | TagKey, TagValue, TagBinding, import IDs, address shapes, verified at organization, folder and project level. The `templatestring` escaping matrix (`$$${`) is DERIVED FROM MODULE SOURCE, not exercised — no seeded value contained `${` (tag IAM unverified) |\n| Billing account IAM | billing-account | **N** | — |\n| PAM entitlements | organization / folder / project `pam_entitlements` factory | **N** | Entitlements are configuration and importable. PAM **grant bindings** never enter the denominator: `inventory.py` sweeps active grants via CAI (`privilegedaccessmanager.googleapis.com/Grant`) whenever IAM is collected and strips matching bindings deterministically (target, role, requester from the grant itself), stamping them in `_meta.pam_grant_exclusions`. Structural exemption, not a waiver; declaring the Grant type in a manifest is refused. Strip logic unit-tested; not yet exercised against a live grant |\n| IAM deny policies | organization / folder / project | **N** | Not a CAI asset type; enumerated automatically by the built-in `gcloud iam policies list --kind=denypolicies` enumerator. Command shape verified, payload shape not yet exercised live |\n| Log exclusions | organization / folder / project | **N** | Neither a CAI asset type nor a gcloud command group; needs REST enumeration recorded in the run report |\n| Workload identity pools / providers | project identity-providers | **V** | WorkloadIdentityPool, WorkloadIdentityPoolProvider, import IDs, address shapes, 30-day soft-delete residue, and org policy constraints verified |\n| Anything else | — | fallback | documented raw-resource fallback + capability-gap report |\n\n## Verification Notes & Caveats\n\n- **Additive IAM default**: all V-level IAM rows above were verified\n  under **authoritative** emission (`iam` / `iam_bindings` maps), which\n  is now the opt-in (`emission.iam: authoritative`). The additive\n  default (`iam_bindings_additive` → `google_*_iam_member.bindings`)\n  is at **C**: addresses and import-ID formats derived from module\n  source and provider docs, not yet exercised live. First verified\n  additive round graduates it and re-stamps.\n- **Factory emission (opt-in)**: all matrix levels above are for the\n  default per-instance emission. The `factory` emission mode (manifest\n  `emission:` block; cookbook \"Factory emission (opt-in)\") is at **C**\n  across all carriers — address shapes and layout rules derived from\n  module source in-repo, no live import exercised yet. First verified\n  run graduates the exercised carrier and stamps it here.\n\n- **CAS & Custom Roles**: CAS `publishing_options` requires Fabric PR #4107 (`a153861aae`). Custom roles title/description convergence requires Fabric PR #4102. Both are released in `v58.0.0`.\n- **Non-CAI types**: CAI is the default source of the denominator, not its boundary. Types it does not model are enumerated with `gcloud` — automatically where `inventory.py` ships a built-in enumerator, otherwise via a manifest `enumerate:` block — or, where gcloud has no container-scoped surface, out of band against the REST API. All of it merges into the same denominator, and `inventory.py` stops the run rather than proceeding without a type it cannot enumerate. See `references/cai-blind-spots.md`.\n- **Provider Label Inheritance**: Provider v5/v6/v7 generates computed `terraform_labels: {} -> {...}` diffs for resources with inherited provider labels. These are accounted for via scoped rules in `scripts/benign-drift.yaml`.\n\n## Regression habit\n\nOn every provider minor bump, Fabric ref bump, or after long dormancy:\nre-run both gates on the persisted workspace. New provider artifacts\nfail loudly as residuals; resolve via value-guarded benign rules in\n`scripts/benign-drift.yaml`, then re-stamp `verified_against`. Never widen\na rule to make a bump pass.\n","skills/fabric-importer/README.md":"# fabric-importer\n\n`fabric-importer` reverse-engineers an existing Google Cloud organization into Cloud Foundation Fabric Terraform modules with native `import` blocks. An AI agent generates the module mapping; a set of frozen verification scripts proves the result is complete, attribute-accurate, and plan-converged. It never modifies anything in your live cloud environment.\n\nStart with the document for your role:\n\n- **Human Operator / Requesting an Import**: Follow this guide below.\n- **AI Agent Performing the Work**: Follow [SKILL.md](./SKILL.md).\n- **Supported Resources & Maturity**: See the [Maturity Matrix](./COVERAGE.md).\n\n---\n\n## The Design in One Paragraph\n\nMost infrastructure importers rely on brittle per-resource generator pipelines that quickly grow stale. This skill inverts that model: the AI agent inspects the live inventory and Fabric module sources directly, then emits idiomatic Fabric module calls paired with native Terraform 1.5+ `import {}` blocks. Two frozen verification gates validate the output. This is sound because every mistake is immediately visible: a missing attribute or schema mismatch causes `terraform plan` to propose unexpected changes, and a missed resource is flagged during inventory reconciliation. Verification per run replaces trust in generator code.\n\n---\n\n## What you provide, and what the tools work out\n\nYou write one file: `import-manifest.yaml`. Everything else is derived from it.\n\nThe important part is what that file contains. **It names resource types, not\nresources.** You never write a list of your 47 org policies. You write:\n\n```yaml\nscopes:\n  - root: organizations/123456789012\n    types:\n      - type: org-policy\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n```\n\nThat means \"in this organization, take over org policies attached at org level,\nand every folder\". Finding the actual 47 policies and 12 folders is the tool's\njob — and it has to be. If a human wrote the list of resources, \"everything is\ncovered\" would only ever mean \"everything I remembered to write down\". The\nmanifest is a deliberately coarse statement of intent; turning intent into an\nexact list is where people miss things, so a frozen script does it and refuses\nto hand back a partial answer.\n\nThe run answers five questions in order, and only the third one is yours:\n\n| Question | Answered by | Binding? |\n|---|---|---|\n| What do I already manage in Terraform? | `manifest_from_state.py` | no, advisory |\n| What actually exists in the cloud? | `inventory.py survey` + `manifest_init.py` | no, advisory |\n| **What do I want under management?** | **you, in `import-manifest.yaml`** | **yes** |\n| Which live resources does that mean, exactly? | `inventory.py collect` → `inventory.json` | yes |\n| How much of it is done? | `coverage.py` | yes |\n\nThe first two exist to break a chicken-and-egg problem: you cannot write a\nsensible manifest about an estate you cannot see, and the tools that measure the\nestate need a manifest first. Neither is a gate, neither produces anything\nbinding, and you can skip both if you already know your scope. Run them anyway\nthe first time: the gap between \"what Terraform manages\" and \"what exists\" is\nusually the most useful thing discovered all week.\n\n### Different depth per subtree: per-scope `types:`\n\nOne estate rarely wants the same depth everywhere — governance at org and\nfolder level, the full connectivity stack in the networking folder, two named\nworkload projects and nothing else. `scopes:` is a list, and **every scope\ncarries its own `types:` list**; there is no other place to declare types, so\nwhat a scope collects is exactly what is written on it:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: org-policy\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: workloads\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111\n    types:\n      - type: iam\n        levels: [project]\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n```\n\nThe same type may appear in several scopes with different `levels`, `iam` or\n`enumerate`. Because a type list decides the denominator, the rules fail the\nrun with a message rather than warning — before any API call, exit 1:\n\n| Rule | Why |\n|---|---|\n| Every scope declares `types:`; `types: []` is rejected | A scope that collects nothing passes every check by not collecting |\n| A type whose `levels` cannot intersect its scope's `levels` is rejected | A dead declaration reads like coverage and produces none (an entry listing `unknown` is exempt) |\n| A type declared twice in one list is rejected | Last-wins would quietly narrow levels |\n| The retired grammar (top-level `scope:` / `types:`) is rejected with migration instructions | Half-accepting it would change what the denominator means — see [references/manifest-migration.md](./references/manifest-migration.md) |\n\n`examples/import-manifest.multi-domain.yaml` is a worked four-scope manifest;\n`examples/import-manifest.org-foundation.yaml` is the single-scope reference.\n\nAfterwards, read the **per-scope** tables in `inventory.json`:\n`_meta.scopes[].declared_types` and `_meta.scopes[].zero_yield_types`.\n`_meta.declared_types` is the aggregate across scopes, and the aggregate can\nhide a scope whose own declaration yielded nothing — a type non-zero org-wide\nand zero in the one scope you cared about. Every inventory entry (and\ntherefore every worklist entry) also names the scope(s) that collected it in\nits `scopes` field.\n\n### `survey` and `collect` are not the same thing\n\nThey are the same script and both list resources, which makes them easy to\nconfuse. Their purposes are opposite.\n\n`survey` runs **without** a manifest. It browses, so that a human can decide. Its\noutput is a conversation aid and is never used to judge anything.\n\n`collect` runs **only** with an approved manifest. Its output is the\n**denominator**: the exact list of live resources matching what you agreed.\nEverything downstream is measured against it.\n\n### The two gates answer different questions\n\nThis is the part worth reading twice, because neither gate can catch the other's\nfailure.\n\n| Gate | Compares | Answers |\n|---|---|---|\n| 1 — `coverage.py` | `inventory.json` against the **text of your code** | did you cover everything? |\n| 2 — `verify_plan.py` | the **Terraform plan** against reality | is what you wrote correct? |\n\nGate 1 never runs Terraform and never contacts your cloud. It reads\n`inventory.json`, the `import {}` blocks it parses out of your `*.tf` files, the\n`coverage-map.yaml` ledger, and `waivers.yaml`. Every asset in the denominator\nmust be in the ledger or in the waivers.\n\nGate 1 can pass on completely wrong code — right addresses, garbage\nconfiguration. The plan catches that, as diffs.\n\nGate 2 can pass on a workspace covering 3 of your 59 resources. A plan of three\nclean imports and nothing else is perfectly converged; Terraform has no idea the\nother 56 exist. **A converged plan says nothing about completeness**, because\nTerraform only knows about what you wrote down. Completeness can only be judged\nagainst a list built independently of the code, which is exactly what\n`inventory.json` is.\n\n### `inventory.json` is the only door\n\nThe loop is closed in both directions:\n\n- a ledger key that is not in the inventory fails gate 1 as `stale`\n- an `import {}` block that no ledger key claims fails gate 1 as an `orphan`\n\nSo nothing can be imported that the denominator does not contain, and nothing in\nthe denominator can be quietly dropped. That is what makes \"both gates green\"\nmean something precise: everything the manifest declared is either in code or\nwaived by name, and applying that code would change nothing.\n\n---\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where human judgment is required (e.g. narrowing scope, waiving assets, or accepting provider drift). Keep mechanical, reversible steps (asset collection, scaffolding, linting, plan checks) autonomous. Gates are **blocking**: if running non-interactively and confirmation cannot be obtained, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Scope Approval** | Manifest drafting, before any enumeration | Reviews and commits `import-manifest.yaml`: which resource types each scope declares, at which hierarchy levels (org/folder/project), under which included/excluded subtrees. |\n| **Waiver Signing** | Completeness Gate (`coverage.py`) | Reviews deliberate exclusions in `waivers.yaml` and signs them with attribution (`signed_by`) for unmanaged or auto-generated resources (e.g. default log sinks, default compute service accounts). |\n| **Benign Drift Review** | Plan Convergence Gate (`verify_plan.py`) | Evaluates proposed cosmetic provider quirks (e.g. computed labels, default timeouts) and commits reviewed entries to `scripts/benign-drift.yaml`. |\n| **Final Review & Apply** | Handover, before `terraform apply` | Inspects the final zero-drift plan, run report, and input provenance digests before running `terraform apply` on their own schedule. |\n\n---\n\n## How the work flows\n\nThree views. The main loop, then the two places where a single item funnels\nthrough a decision — enumeration and mapping.\n\n### The main loop\n\n```mermaid\nflowchart TD\n    A[\"Existing .tfstate files<br/>or the live estate\"] --> B[\"Draft import-manifest.yaml<br/>manifest_from_state.py, or<br/>inventory.py survey + manifest_init.py\"]\n    B --> G0{{\"HUMAN GATE 1<br/>approve the scope\"}}\n    G0 --> C[\"inventory.py collect\"]\n    C --> D[(\"inventory.json<br/>THE DENOMINATOR\")]\n    D --> E[\"coverage.py --worklist-out\"]\n    E --> F[\"AGENT WORK<br/>module config + import blocks<br/>+ coverage-map.yaml\"]\n    F --> G1{\"GATE 1 machine<br/>coverage.py\"}\n    G1 -- \"missing assets\" --> W{{\"HUMAN GATE 2<br/>sign a waiver\"}}\n    W --> G1\n    G1 -- \"gaps to close\" --> F\n    G1 -- \"reconciled, exit 0\" --> P[\"terraform plan\"]\n    P --> G2{\"GATE 2 machine<br/>verify_plan.py\"}\n    G2 -- \"residual diff\" --> F\n    G2 -- \"provider quirk?\" --> BD{{\"HUMAN GATE 3<br/>accept benign drift\"}}\n    BD --> G2\n    G2 -- \"converged, exit 0\" --> R[\"Run report\"]\n    R --> NEXT[\"Widen the manifest<br/>for the next round\"]\n    NEXT --> G0\n    R --> G3{{\"HUMAN GATE 4<br/>apply sign-off\"}}\n    G3 --> DONE([\"terraform apply<br/>run by the operator\"])\n```\n\nEverything above the scope gate is advisory. Everything below it is mechanical\nand binding.\n\n### Funnel 1: how a declared type becomes inventory entries\n\n```mermaid\nflowchart TD\n    T[\"One type declared in a<br/>scope's types: list\"] --> Q1{\"pseudo-type?\"}\n    Q1 -- \"iam\" --> S1[\"asset list<br/>--content-type=iam-policy\"]\n    Q1 -- \"org-policy\" --> S2[\"three streams merged:<br/>org-policy content type<br/>+ orgpolicy Policy assets<br/>+ gcloud org-policies list<br/>per container\"]\n    Q1 -- \"no, a real asset type\" --> Q2{\"enumerator registered?<br/>built-in table, or a<br/>manifest enumerate: block\"}\n    Q2 -- \"yes\" --> S3[\"gcloud list per container<br/>CAI never consulted\"]\n    Q2 -- \"no\" --> S4[\"gcloud asset list<br/>batched with the other types\"]\n    S4 --> Q3{\"outcome\"}\n    Q3 -- \"ok\" --> OUT\n    Q3 -- \"type not in the<br/>CAI catalogue\" --> STOP[\"STOP, exit 3<br/>fix the type string,<br/>add an enumerator,<br/>or drop it deliberately\"]\n    Q3 -- \"any other error<br/>403, ACM types\" --> S5[\"asset search-all-resources\"]\n    S5 -- \"ok\" --> OUT\n    S5 -- \"still failing\" --> STOP2[\"STOP, exit 3<br/>partial denominator refused\"]\n    S1 --> OUT[(\"inventory.json\")]\n    S2 --> OUT\n    S3 --> OUT\n```\n\nNo path exits with \"skip it\". Every branch ends in the denominator or in a stop,\nbecause an unenumerated asset is invisible to both gates at once — it would pass\nevery check by not existing. The funnel runs once per scope: the same type can\nenter it from two scopes' lists with different levels, and a type declared in\nonly one scope is swept only there.\n\n### Funnel 2: how a worklist item becomes code\n\n```mermaid\nflowchart TD\n    I[\"Worklist item\"] --> M{\"Does the canonical<br/>Fabric module express<br/>the live resource?\"}\n    M -- \"yes\" --> A[\"Module config<br/>+ import block<br/>+ coverage-map entry\"]\n    M -- \"module exists but cannot<br/>express an attribute\" --> B[\"Raw google_* resource<br/>+ MODULE CAPABILITY GAP<br/>in the report\"]\n    M -- \"no module covers<br/>the type at all\" --> C[\"Raw google_* resource<br/>+ report, propose upstream\"]\n    M -- \"deliberately out of scope\" --> D{{\"HUMAN GATE<br/>signed waiver\"}}\n    M -- \"a residual you<br/>cannot explain\" --> STOP[\"STOP<br/>report it, never rationalise it\"]\n    A --> P[\"Both gates\"]\n    B --> P\n    C --> P\n    D --> X[\"Not in the workspace,<br/>named in the report\"]\n```\n\nThree of those four outcomes are successes. That matters: the temptation at the\nmiddle two is to force the module to fit, or to call the leftover diff benign.\n\n---\n## Step-by-Step Operator Guide\n\n### 1. Declare the Scope (`import-manifest.yaml`)\nYou and the agent agree on an `import-manifest.yaml` declaring, per scope, which resource types to import (e.g., folders, service accounts, VPC networks, org policies), at which container levels (organization, folder, or project), and which subtrees to include or exclude — see [Different depth per subtree](#different-depth-per-subtree-per-scope-types).\n\nThis is the only file you author, and it names types, not resources. The two\noptions below are drafting aids that answer different questions — A tells you\nwhat Terraform already manages, B tells you what exists. Run both the first\ntime and compare them; run neither if you already know your scope. Neither\noutput is binding until you have reviewed it and committed it.\n\n**Option A — Inferred from existing Terraform state(s):**\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n```\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate:**\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json --scope organizations/ORG_ID --out import-manifest.yaml\n```\nReady-made examples aligned with FAST stages are provided in\n[`examples/`](./examples/); `import-manifest.org-foundation.yaml` is the\nsingle-scope reference and `import-manifest.multi-domain.yaml` the per-scope\nmulti-domain one.\n\n### 2. Enumerate the Denominator (`inventory.json`)\nThe enumeration script queries Cloud Asset Inventory (CAI) and service APIs to construct the exact denominator of all matching live assets:\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nCAI is the default source of the denominator, not its boundary. CAI does\nnot model every GCP resource, and a type it cannot see would otherwise be\ninvisible to both gates at once — so the tool routes around it rather\nthan shrinking the denominator: built-in `gcloud` enumerators for types\nknown to be absent from the catalogue (declaring the type is all it\ntakes), automatic sibling sweeps for the Compute families whose primary\nCAI surface splits them into separate scoped types (accounted under the\ndeclared type, real type preserved in `_meta.split_type_sweeps`), and a\nhard stop with the remedy where no enumerator exists — either the type\nstring is wrong (checked against the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types)),\nor the manifest needs a native `enumerate:` block: a read-only `gcloud`\ncommand run per in-scope container, normalized into the same inventory,\ndeclared in the `types:` list of each scope that needs it (per-scope\nlists never inherit). The full ladder (CAI → gcloud → REST API → signed\nwaiver), the enumerator syntax and guard rails, and the split-taxonomy\nmechanics are in\n[references/cai-blind-spots.md](./references/cai-blind-spots.md).\n\nThe split-type table is a frozen snapshot of a document Google changes,\nso check it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nIt costs one extra `search-all-resources` call per scope and fails the\nrun if the search surface returns an asset the list sweep did not.\n\nThe run stays auditable without becoming unreadable: every run closes\nwith a one-line cost summary on stderr (`7 gcloud call(s) in 12.0s:\nasset list x5, org-policies list x2`), `--verbose` (after the\nsubcommand) prints each command as it runs with outcome, duration and\nitem count, the full log is written to `_meta.api_calls`, and every\nnon-CAI sweep is recorded verbatim in `_meta.native_sweeps` so a\nreviewer can re-run it.\n\nProvenance is per scope: each `_meta.scopes[]` entry records its own\n`declared_types` yield counts and `zero_yield_types`, and every\ninventory entry names the scope(s) that collected it.\n`_meta.declared_types` is the aggregate — read the per-scope counts\ntoo, because a type that yields zero in one scope and non-zero in\nanother does not show up in the aggregate zero-yield warning (the\nper-scope warning on stderr does).\n\n### 3. Compute Delta Worklist\nThe completeness tool reconciles the denominator against any existing code and written waivers. This is the same program that runs as gate 1 in step 5 — the same comparison, run for a different reason. Here it tells you what is left to do; there it decides whether anything is. On the first round everything is outstanding, so the worklist is the whole denominator. On later rounds it is only the delta, which is what makes repeated rounds cheap:\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\n### 4. Agent Maps & Emits Terraform\nFor each item in `worklist.yaml`, the agent writes:\n1. Canonical Fabric module configuration matching the live state.\n2. A paired Terraform `import {}` block:\n   ```hcl\n   import {\n     to = module.folder-team-a.google_folder.folder[0]\n     id = \"folders/123456789012\"\n   }\n   ```\n3. A ledger entry in `coverage-map.yaml` linking the asset to its Terraform address.\n\n### 5. Automated Verification (Definition of Done)\nWork is complete only when two independent gates pass with exit code `0`. They are independent in a specific sense: each catches a failure the other cannot see. Gate 1 can pass on wrong code, and gate 2 can pass on a workspace covering three resources out of fifty-nine. See [The two gates answer different questions](#the-two-gates-answer-different-questions).\n- **Completeness Gate (`coverage.py`)**: Reconciles the inventory against `coverage-map.yaml`, `waivers.yaml`, and emitted `import {}` blocks. Every asset must be mapped or waived. It reads files only — no Terraform, no cloud access — so it needs neither credentials nor a `terraform init` to run.\n- **Plan Convergence Gate (`verify_plan.py`)**: Consumes `terraform show -json <planfile>` output (file argument or stdin) and classifies the planned actions. It never invokes Terraform itself. The plan may only contain clean imports, no-ops, and human-reviewed benign drift entries from `scripts/benign-drift.yaml`. Any residual attribute modification or destruction fails the gate.\n\nRun both gates like this:\n\n```bash\nterraform -chdir=tf fmt -recursive\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # a stale plan must never answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` to the plan: it returns 2 for any\nnon-empty plan, and a converged import plan is always non-empty.\n`verify_plan.py` is the verdict.\n\n### 6. Review and Apply\nThe resulting workspace (`tf/`) is clean, formatted, and ready for you to review and run `terraform apply` at your own pace. The skill **never** runs `terraform apply` or mutates live cloud resources.\n\n---\n\n## Script reference\n\nRun every script from the skill directory. All output paths are relative\nto the current working directory.\n\n| Script | Purpose | Flags |\n|---|---|---|\n| `inventory.py survey` | Enumerate everything in scope, to draft a manifest from | `--scope` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants` |\n| `manifest_init.py` | Draft a manifest from a survey (Mode B) | `--survey` (required), `--scope` (required), `--out` |\n| `manifest_from_state.py` | Infer a manifest from existing `.tfstate` (Mode A) | `--state` (1+, required), `--out` (`-` for stdout), `--force` to overwrite an existing manifest |\n| `inventory.py collect` | Build the denominator from CAI | `--manifest` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants`, `--verify-search-parity` |\n| `coverage.py` | Gate 1 — completeness | `--inventory` (required), `--workspace` (required), `--coverage-map`, `--waivers`, `--require-signed-waivers`, `--allow-empty-inventory`, `--worklist-out` |\n| `verify_plan.py` | Gate 2 — plan convergence | positional plan JSON (default stdin), `--rules`, `--allow-empty-plan` |\n| `integrity.py` | Print the frozen-tools provenance digest | `--verbose` for per-file digests |\n\n`inventory.py` requires the `survey` or `collect` subcommand; it has no\ntop-level flags. `--verbose` therefore goes after the subcommand\n(`inventory.py collect --manifest m.yaml --verbose`), not before it.\n\nExit codes — `coverage.py`: `0` reconciled, `1` malformed input, `2` gaps\nor problems. `verify_plan.py`: `0` converged, `1` malformed input, `2`\nresidual changes, `3` converged but ADVISORY (a substituted `--rules`\nfile). A substituted ruleset can never exit `0`; a residual plan still\nexits `2`. `inventory.py`: `0` ok, `1` a manifest the validator refuses\n(retired grammar, missing/empty per-scope `types:`, duplicate type,\ninvalid level, dead per-scope declaration — printed with the reason\nbefore any API call), `3` a partial denominator (unsupported type,\ntolerated sweep failure, split-parity gap). Note that argparse usage\nerrors also exit `2`, so read the message, not only the code.\n\nThere is no checked-in expected digest. To check a recorded gate run,\ncompute `uv run scripts/integrity.py` from a pristine checkout of the\nsame commit and compare it with the `frozen tools:` line in the captured\noutput.\n\n---\n\n## Prerequisites & Permissions\n\n### System Requirements\n- **Terraform**: `v1.5.0+` (required for native `import {}` blocks).\n- **Google Cloud SDK (`gcloud`)**: Authenticated with a configured quota project.\n- **[`uv`](https://docs.astral.sh/uv/)**: the recommended way to run the scripts.\n  Every script declares its own dependencies inline\n  ([PEP 723](https://peps.python.org/pep-0723/), the same convention as\n  `tools/tfdoc.py`), so `uv run scripts/<name>.py` resolves them per script with\n  nothing to install and nothing to activate.\n- **Python**: `3.10+` if you would rather not use `uv`. Then run the scripts with\n  `python3` and install `PyYAML` yourself; the commands are otherwise identical.\n\n### Minimal Read-Only IAM Permissions\nRun the import using a dedicated read-only identity. The following roles on the organization scope provide full read visibility:\n\n| Role | Purpose |\n|---|---|\n| `roles/cloudasset.viewer` | CAI asset inventory enumeration across the hierarchy |\n| `roles/resourcemanager.organizationViewer` | Organization resource details |\n| `roles/resourcemanager.folderViewer` | Folder hierarchy discovery |\n| `roles/viewer` | Read-only inspection of imported resources via service APIs |\n| `roles/iam.securityReviewer` | IAM policy discovery at organization, folder, and project levels |\n| `roles/orgpolicy.policyViewer` | Organization policy evaluation |\n| `roles/accesscontextmanager.policyReader` | VPC Service Controls discovery (when VPC-SC is in scope) |\n\nNo write or admin permissions are required.\n\n---\n\n## User Ownership Boundary\n\nThree human-owned files govern the process. The agent may propose edits, but only you commit them:\n\n| File | Purpose |\n|---|---|\n| `import-manifest.yaml` | Declares the scopes, each with its own resource-type list, container levels, and subtree filters. |\n| `waivers.yaml` | Written waivers for resources deliberately excluded from management (e.g. `_Default` log sinks, default compute service accounts). Requires a `signed_by` attribute. |\n| `scripts/benign-drift.yaml` | Scoped provider quirks accepted as cosmetic diffs (e.g., computed provider labels or default timeouts). |\n| `references/address-map.yaml` | The fielded subset of the mapping cookbook: asset type, module, address pattern, import ID, verification round. Advisory, not a gate input — see below. |\n\n---\n\n## Repository Layout\n\n```text\nskills/fabric-importer/\n├── SKILL.md                     # Agent protocol and operational contract\n├── README.md                    # Human operator guide and architecture overview\n├── COVERAGE.md                  # Resource family maturity matrix (N / C / V)\n├── scripts/                     # Verification gates and helper utilities\n│   ├── inventory.py             #   Asset enumeration (CAI + API sweeps)\n│   ├── coverage.py              #   Completeness gate & delta worklist generator\n│   ├── verify_plan.py           #   Plan-convergence gate (benign-drift aware)\n│   ├── manifest_from_state.py   #   Terraform state-driven manifest inference\n│   ├── manifest_init.py         #   Starter manifest drafting assistant\n│   ├── integrity.py             #   Input binding & runtime provenance stamping\n│   ├── address_map.py           #   Address-map validator, cookbook linter, table renderer\n│   └── benign-drift.yaml        #   Human-reviewed provider quirk ruleset\n├── examples/                    # Manifest and waiver templates\n│   ├── import-manifest.org-foundation.yaml\n│   ├── import-manifest.multi-domain.yaml\n│   ├── import-manifest.fast-org-setup.yaml\n│   ├── import-manifest.fast-security.yaml\n│   ├── import-manifest.fast-vpcsc.yaml\n│   ├── import-manifest.fast-networking.yaml\n│   └── waivers.example.yaml\n├── references/                  # Normative mapping rules & technical gotchas\n│   ├── mapping-cookbook.md      #   Traps, reasoning, capability gaps (prose)\n│   ├── address-map.yaml         #   Addresses, import IDs, asset types (data)\n│   ├── inferring-manifests-from-state.md # State-driven manifest inference workflow\n│   ├── manifest-migration.md    #   Migrating manifests from the retired grammar\n│   ├── cai-blind-spots.md       #   Known CAI gaps and service API mitigations\n│   └── operating-contract.md    #   Safety invariants and trust boundaries\n└── tests/                       # Unit test suite\n```\n\n### Where mapping knowledge lives\n\nThe cookbook carries two different kinds of knowledge, and they are now\nstored differently because they fail differently.\n\n**Fielded tuples** — asset type, module, address pattern, import ID,\nverification round — live in `references/address-map.yaml`. They are\nuniform and mechanically checkable, and they were the part that kept\nbeing duplicated: the same address appeared in per-type prose and again\nin a hand-maintained markdown table, and three families were once\ndocumented twice because nothing could detect it. Two invariants are now\nenforced:\n\n- an address pattern is claimed by exactly one entry;\n- a CAI asset type serviced by more than one module (the live case:\n  `compute.googleapis.com/Router`, created by both `net-cloudnat` and\n  `net-vpn-ha`) must say how to tell the carriers apart.\n\n**Reasoning** — ForceNew traps, provider quirks, waiver postures,\ncapability gaps — stays in `references/mapping-cookbook.md`, in prose,\non purpose. It is causal and only degrades when squeezed into a schema.\n\n```bash\nuv run scripts/address_map.py                      # validate + lint\nuv run scripts/address_map.py --render-cookbook    # regenerate the table\nuv run scripts/address_map.py --check-workspace tf/  # advisory, always exits 0\n```\n\n`address_map.py` is a linter, **not a gate**. `coverage.py` and\n`verify_plan.py` never read the address map, and a test enforces that.\nA wrong entry is caught by `terraform plan` erroring loudly, unlike a\nwrong `benign-drift.yaml` entry which would silently pass a gate. The\nmap is also deliberately incomplete: an address it does not recognise is\na type nobody has mapped yet, which SKILL.md calls the normal case.\n","skills/fabric-importer/SKILL.md":"---\nname: fabric-importer\ndescription: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n---\n\n# Fabric Importer — agent protocol\n\nYou (the agent) reverse-engineer live GCP resources into Cloud Foundation\nFabric Terraform. There is no per-resource pipeline: **you perform the\nmapping yourself**, guided by the cookbook, and two frozen gates verify\nyour output. Trust comes from the gates, not from you.\n\n## Trust boundary (non-negotiable)\n\n- **Frozen scripts** — everything in `scripts/`, rulesets included\n  (`benign-drift.yaml`). You may RUN them; you must NEVER modify them.\n  The helpers are frozen for the same reason as the gates: they decide\n  the SCOPE the denominator is built from, so editing one shrinks what\n  \"complete\" means. Both gates print runtime provenance and active input hashes;\n  a reviewer compares them against a clean checkout. If a gate is\n  genuinely broken, report it with evidence — do not patch around it.\n- **Human-owned files**: the import manifest and the waiver ledger. You\n  may draft or propose changes; a human reviews and commits them.\n- **Yours**: everything in the workspace (`tf/`, `coverage-map.yaml`,\n  reports). Constraint: once an address exists, it is immutable — extend,\n  never rewrite (see \"Incremental runs\").\n\n## Safety contract\n\n1. **NEVER run `terraform apply`.** The workspace contains authoritative\n   surfaces (org policies, sinks — and authoritative IAM where opted\n   into), so an apply is equivalent to overwriting live config with a\n   snapshot. The additive-IAM default narrows that blast radius; it does\n   not lift this rule. Verification is plan-only via `verify_plan.py`.\n   Never synthesize state files.\n2. **Read-only against GCP**: `list`/`describe`/`get-iam-policy`/asset\n   export only. Prefer a dedicated read-only identity\n   (`roles/cloudasset.viewer` + per-service viewers, via impersonation)\n   so an accidental apply fails with 403.\n3. **All workspace output is org-confidential.** Fabric is used\n   fork-and-own, so during the OWN phase these artifacts legitimately\n   live in the operator's own private fork alongside the code — that is\n   the expected working directory. What must never happen is any of it\n   reaching a PUBLIC repository, an upstream pull request, or a document\n   that leaves the engagement: no org/folder/project ids, principals,\n   resource names or asset counts. Pulled `.tfstate` and saved\n   `*.tfplan` additionally contain secret VALUES (Secret Manager\n   payloads, generated keys); delete them once the manifest is drafted\n   and the plan is verified, and never commit them anywhere.\n4. **Never rationalize a residual diff.** If you believe a plan diff is a\n   provider artifact, propose a `benign-drift.yaml` entry with evidence in\n   your report and stop. A human accepts it or the run stays red.\n\n## Human-in-the-Loop Gates\n\nFour decisions are human-owned and BLOCKING: scope approval (the\nmanifest), waiver signing (`signed_by` in `waivers.yaml`), benign-drift\nacceptance (`scripts/benign-drift.yaml`), and the final apply sign-off.\nEach is called out at its step below; the README's gate table is the\noperator-facing contract. Keep mechanical, reversible steps (asset\ncollection, scaffolding, linting, plan checks) autonomous. If running\nnon-interactively and confirmation cannot be obtained, stop — never\nassume approval.\n\n## Operational Workflow\n\n### Step 0 — establish the manifest (with the user)\n\nThe manifest is the user's contract: which resource types, at which\nlevels, under which subtrees — declared per scope, since `scopes:` is\na list and **every scope carries its own `types:` list**. Never invent\nit silently. If one exists, confirm it; otherwise run one of the\ndrafting workflows:\n\n**Option A — Inferred from existing Terraform state(s)** (preferred when migrating existing TF):\n\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n# add --force to replace an existing manifest, or --out - to review first\n```\n\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate**:\n\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json \\\n  --scope organizations/ORG_ID --out import-manifest.yaml\n```\n\nThen interview the user over the draft (every discovered type is listed\nwith per-level counts, commented out; foundation types are pre-enabled):\n\n- What do you want under Terraform management *now*? (Start narrow —\n  org foundation first, workloads later. Show them\n  `examples/import-manifest.org-foundation.yaml` as the reference.)\n- At which levels? (e.g. org IAM yes, project IAM no.)\n- Same depth everywhere, or different depths per subtree? (Different ⇒\n  several scopes, each with its own `types:` list —\n  `examples/import-manifest.multi-domain.yaml` is the reference.)\n- Any subtrees to exclude (sandboxes, decommissioning trees)?\n\nWhen writing a scope's `include`/`exclude`, note that CAI `ancestors`\nstore NUMERIC project numbers; `inventory.py` automatically resolves\nalphanumeric project IDs to numbers during enumeration, but project numbers\nremain supported and canonical.\n\n**Per-scope `types:` rules (fail-closed, exit 1 before any API call).**\nA scope's list is the only place types are declared, so what a scope\ncollects is exactly what is written on it. The same type may appear in\nseveral scopes with different `levels`, `iam` or `enumerate`. The\nvalidator refuses: a scope without a `types:` list or with `types: []`\n(a scope that collects nothing may not say so quietly); a type whose\n`levels` cannot intersect its scope's `levels` (dead declaration —\nentries listing `unknown` are exempt); a duplicate `type:` within one\nlist; and the retired top-level `scope:`/`types:` grammar, which is\nrejected with migration instructions — a manifest from an earlier\nengagement is migrated per\n[references/manifest-migration.md](./references/manifest-migration.md).\nUnlike `types:`, a scope's\n`emission:` map inherits built-in defaults for omitted families — it\nis denominator-neutral; do not reason from one knob to the other.\n\nThe user commits the manifest. Scope changes later = manifest edit +\nincremental re-run, never a rewrite.\n\n### Step 1 — enumerate (the denominator)\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nBy default, `inventory.py survey` and `inventory.py collect` filter out\nsoft-deleted containers (`DELETE_REQUESTED` / `DELETE_IN_PROGRESS` folders and\nprojects) and their child resources, matching what is active in the GCP Console.\nPass `--include-deleted` if you explicitly need to capture soft-deleted assets in\nthe denominator (e.g. to audit or waive them).\n\nSimilarly, resources GCP creates or manages on the operator's behalf —\nun-creatable auto-generated VPC routes (subnet-local routes, NCC and peering routes),\nGoogle-managed default log sinks and log buckets (`_Default` and `_Required`),\nand ephemeral Privileged Access Manager (PAM) grants (`privilegedaccessmanager.googleapis.com/Grant`) —\nare automatically excluded from the denominator. Pass `--include-auto-generated`\n(or `--include-auto-generated=family,...` e.g. `routes`, `logging-defaults`, `pam-grants`;\n`--include-logging-defaults` and `--include-pam-grants` remain supported as aliases)\nif you explicitly need to capture them in the denominator.\n\n**CAI is the default source of the denominator, never the boundary of\nit.** Cloud Asset Inventory does not model every GCP resource. A type it\ndoes not support must be enumerated by other means and merged into the\nsame denominator — never dropped: an unenumerated asset is invisible to\nBOTH gates at once, which is the exact failure the gates exist to\nprevent. `inventory.py` handles the known cases itself: built-in gcloud\nenumerators for types absent from the CAI catalogue\n(`NATIVE_ENUMERATORS`), automatic sibling sweeps for the Compute\nfamilies whose list surface splits them into separate scoped types\n(stamped in `_meta.split_type_sweeps`, per entry as `cai_list_type`),\nand a hard stop — separated from permission failures — where no\nenumerator exists. When a sweep stops, or a declared type yields less\nthan you expected, read\n[references/cai-blind-spots.md](./references/cai-blind-spots.md) BEFORE\nworking around it: it holds the full remedy ladder (wrong type string →\nsplit CAI taxonomy → native `enumerate:` block, declared in the `types:`\nlist of each scope that needs it, since per-scope lists never inherit →\nREST API), the guard rails for enumerator blocks, and the built-in\ntable. The manifest is human-owned: you draft an enumerator block, a\nhuman commits it; one that worked belongs in your report as a proposed\naddition to the built-in table.\n\nThe split-type table is a frozen snapshot of a doc that changes, so\ncheck it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra call per scope; fatal if the search surface returns an asset\nthe list sweep did not. `_meta.split_parity` EMPTY means the probe did\nnot run; a probe that ran and found nothing is a record whose\n`only_in_search` is empty. Read the record, not the key — and say which\nin the report.\n\nEvery entry that did not come from CAI is stamped into\n`inventory.json`'s `_meta.native_sweeps` with the verbatim command;\nthose entries, and `_meta.split_type_sweeps`, belong in the step-5\nreport. Collection closes with a one-line cost summary (`N gcloud\ncall(s) in Xs: …`) and records every command it ran in\n`_meta.api_calls`; add `--verbose` after the subcommand to watch each\ncall as it happens, and quote the summary line in the report — on a\nlarge estate the per-container org-policy sweep dominates the run, and\nthis is what makes that visible instead of guessed at.\n\n### Step 2 — get your worklist\n\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\nFirst run: everything is missing. Re-runs: only the delta.\n\n### Step 3 — map and emit (your job)\n\n**Fabric modules are the product, not an optimization.** Every resource\nmaps to its canonical Fabric module (the cookbook's \"Canonical module\nmap\" is normative): `modules/organization` for everything org-level,\nand — by default — one `modules/folder` instance per folder and one\n`modules/project` instance per project, each carrying its own IAM, org\npolicies, and sinks. For families where Fabric also offers a factory\ncarrier (e.g. `project-factory` for the folder hierarchy,\n`net-firewall-policy` rule factories), the manifest may opt into\n`factory` emission per resource family via `emission:` — a human call,\nmade when the imported workspace is meant to become the day-2\noperating model (e.g. foundational folders + org policies + IAM as\nYAML data). The default stays per-instance: shallow, chosen addresses\nwith no coupling to factory internals. The cookbook's \"Factory\nemission (opt-in)\" section is normative for how, and for the\ntradeoffs.\n\nFor each worklist entry, emit module config (factory YAML or module\ninputs, per the cookbook) + a paired import block targeting the\nmodule-internal address, maintaining `tf/coverage-map.yaml`\n(inventory key → list of Terraform addresses). Read\n`references/mapping-cookbook.md` FIRST: it encodes the hard-won rules\n(escaping, import-ID formats, coalesce traps, dry-run keys, hashed\ncondition keys).\n\n**IAM is emitted additive by default.** Bindings map to\n`iam_bindings_additive` (one `google_*_iam_member` per role/member/\ncondition tuple): an apply can only create or destroy the exact pairs\nemitted and can never strip members it does not manage — the posture\nGoogle itself recommends for PAM coexistence. Authoritative emission\n(`iam` / `iam_bindings`) is a deliberate opt-in via `emission.iam:\nauthoritative` in the manifest, for estates that want IAM fully\ndeclarative as the day-2 model; the cookbook's container-IAM rules\ncover both and the tradeoff.\n\n**Machine-managed IAM is excluded, never imported — and never part of\nthe denominator.** `inventory.py` strips Privileged Access Manager\ngrant bindings deterministically before the denominator is formed —\nnothing to map, nothing to waive — and stamps them in\n`_meta.pam_grant_exclusions`; report them in step 5, like `deleted:`\nprincipal tombstones (which remain a mapping-time exclusion). PAM\n*entitlements* are ordinary importable configuration; only *grants*\nare excluded. The cookbook's container-IAM rules are normative,\nincluding the strip mechanics.\n\nWhen the cookbook has no section for a type — which is the normal case,\nnot an error — follow the method below. The cookbook is the precipitate\nof that method, not a precondition for it.\n\n**Raw `google_*` resources are the exception, never a shortcut.**\nPermitted only when (a) the canonical module cannot express a required\nattribute of the live resource (a *module capability gap*), or (b) no\nFabric module covers the type at all. Every raw resource MUST appear in\nthe step-5 report's \"module capability gaps\" section with the concrete\nreason. For drafting raw config,\n`terraform plan -generate-config-out=generated.tf` is provider-faithful\nby construction — but it is an inspection technique, not a mapping\ntier.\n\nRead the Fabric module source **in this repository** before targeting\nmodule-internal addresses; do not trust memory. Root-module wiring\n(`versions.tf`, provider config, module blocks) is part of your output.\n\nThree cookbook sections are normative for HOW you emit: **Workspace\nlayout** (one file per CONTAINER; project-contained resources live in\ntheir project's file; hierarchical `data/` keyed by instance keys),\n**The reference rule** (managed resources are referenced via module\noutputs, never literals), and **Scaffolding scripts** (run-local\ngenerators are fine — parameterized, cookbook-conformant, deterministic,\nnever promoted into the skill).\n\n### Step 3b — when the cookbook has never seen this type\n\nThe cookbook will never be comprehensive, and it is not meant to be. It\nis the accumulated residue of running this loop against real resources;\nevery entry in it was produced this way. Meeting an undocumented type is\nroutine — apply the same method, at the same standard of evidence.\n\nWhat makes that safe is division of labour. **The gates make the method\nsafe**: you cannot take a wrong mapping to green, because a bad import\nID errors, a mismatched attribute stays residual, and an unmapped asset\nfails coverage. **The cookbook makes it cheap and correctly diagnosed**:\nit is the difference between converging on the first attempt and the\nthird, and — more importantly — between identifying a diff's cause and\nconfidently misreading it. The gates cannot catch a misdiagnosis, because\nclassifying a diff as benign is a human judgement they delegate. Do not\ntreat an absent cookbook section as licence to reason loosely.\n\nThe loop:\n\n1. **Census the type.** Confirm the CAI asset-type string against live\n   `gcloud asset list` output — type strings from module knowledge have\n   been wrong before, and a non-zero yield is NOT proof the type is\n   complete (split families answer happily with part of the family).\n   Record which enumeration path had to be used. Where the type has any\n   global/regional/zonal distinction, census BOTH surfaces and\n   reconcile the counts before trusting either; where CAI does not\n   model the type at all, enumerate it another way and merge it into\n   the same denominator (step 1, `references/cai-blind-spots.md`) —\n   never narrow the manifest around it.\n2. **Find the module and read its source.** Canonical module map first,\n   then `modules/` in this repository. Read the resource blocks for\n   addresses and `for_each` key formats, and the variables file for the\n   input shape. Never work from memory about module internals.\n3. **Mirror ForceNew inputs to live values BEFORE the first plan.**\n   Check which inputs are ForceNew on the underlying resource and set\n   them to exactly what is live. A destroy/create pair on an imported\n   resource is always a mapping error, never an acceptable outcome.\n4. **Emit, plan, and read the result.** The plan is the oracle. A wrong\n   import ID fails loudly and safely; a wrong attribute shows up as a\n   diff. This step is cheap — use it rather than deliberating.\n5. **Diagnose every diff before classifying it**, in this order:\n   - Read the module's variable TYPE. Terraform silently discards\n     attributes a variable type does not declare — no error, `validate`\n     returns Success — so a field you believe you set may never have\n     reached the resource.\n   - Check whether the field is assigned directly or wrapped in\n     `coalesce()`. Directly assigned fields accept `null` and need no\n     rule; `coalesce` fields cannot express \"empty\" at all.\n   - Check the escaping matrix if the value passes through\n     `templatestring()`.\n   Only after those does the question \"is this benign?\" become\n   answerable.\n6. **Land on one of three outcomes — all legitimate:** clean\n   convergence; a *module capability gap* (the module cannot express an\n   attribute that changes behaviour — emit a raw resource mirroring\n   live values, name the module and the missing attribute in the\n   report's capability-gaps section; a successful outcome and\n   upstream-Fabric-issue material); or *no module covers the type*\n   (same, plus whether a module is worth proposing upstream). A\n   residual you cannot explain is none of these: report it and stop.\n7. **Write the cookbook entry as part of your report.** A type you\n   mapped and verified is knowledge the next engagement should not have\n   to rediscover: the CAI type string, the module addresses and\n   `for_each` key formats, the import ID, the ForceNew inputs, and every\n   trap that cost you a cycle. Mark surfaces you did not exercise as\n   unverified. This step is what makes the tool compound instead of\n   resetting to zero on every engagement — without it, the same traps\n   are paid for repeatedly.\n\n### Step 4 — run the gates\n\n**The two gates measure different things, and neither can catch the\nother's failure.** Gate 1 compares the denominator against the TEXT of\nyour workspace — the `import {}` blocks it parses out of `*.tf`, the\ncoverage map, the waivers. It never runs Terraform and never reads your\ncloud. Gate 2 compares the plan against reality.\n\nSo gate 1 passes happily on code that is completely wrong, and gate 2\npasses happily on a workspace covering three of fifty-nine assets: a\nplan of three clean imports is perfectly converged, because Terraform\nonly knows about what you wrote down. **A converged plan is not evidence\nof completeness**, and a reconciled coverage report is not evidence of\ncorrectness. Never report one as if it covered the other, and never\nskip a gate because the other one is green.\n\n**An orphan import block is evidence about the DENOMINATOR, not just\nabout the coverage map.** When gate 1 reports an `import {}` block\ntargeting a resource that is not in `inventory.json`, and you know that\nresource is live, the first hypothesis is that enumeration missed it —\nnot that the mapping is spurious. This is the only signal that fires for\na silently short denominator, and it has been mistaken for a coverage-map\nproblem and waived away in a live run. Investigate the enumeration path\nfor that type (step 1, and `references/cai-blind-spots.md`) BEFORE\nproposing a waiver: a waiver over a short denominator makes the gap\npermanent and puts a human's name on it.\n\n```bash\nterraform -chdir=tf fmt -recursive   # generated code is ALWAYS fmt-ed\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # never let a stale plan answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` here: it returns **2 whenever the plan is\nnon-empty**, and a converged import plan is always non-empty (every\n`import {}` block is a planned change). Under `set -e`, or to an agent\ntreating non-zero as failure, it aborts the workflow at exactly the step\nthat is meant to validate it. `verify_plan.py` is the verdict, not\nterraform's exit code.\n\nThe `rm -f` matters: `plan` and `show` are separate commands, so if the\nplan step fails (expired credentials, a provider error, a syntax error in\nnewly emitted HCL) `show -json` will happily read the PREVIOUS\niteration's plan file and the gate can print CONVERGED for code that was\nnever planned.\n\n`verify_plan.py` exit codes: `0` converged, `1` malformed input, `2`\nresidual changes, `3` advisory run (a substituted `--rules` file — never\na passing gate).\n\n`--require-signed-waivers` is the default posture: every waiver must\ncarry a `signed_by` recording the human who accepted it. Drop the flag\nonly in a fully unattended run, and say so in the report.\n\nIterate step 3 until **both** gates are green. Convergence =\ncoverage reconciled ∧ every plan change is a clean import, no-op, or\nreviewed-benign.\n\n### Step 5 — report\n\nProduce a run report for the user: what was imported (counts by type,\nwith the Fabric module used for each), an **enumeration sources**\nsection naming every type that did not come from CAI and the exact\ncommand that produced it (`_meta.native_sweeps` covers declared\nenumerators; quote anything you enumerated out of band yourself), a\n**module capability gaps** section — every raw resource emitted, which\nmodule fell short, which attribute, and whether it is\nupstream-Fabric-issue material — plus\nbenign diffs relied on, waivers in effect, proposed waivers/benign rules\nawaiting human review, and anything the manifest excludes that you\nbelieve deserves attention. State whether `--verify-search-parity` was\nrun and what it found. An empty `_meta.split_parity` means the probe did\nnot run; a clean probe is a record whose `only_in_search` is empty.\nReporting an unchecked table as clean is exactly the kind of claim this\nsection exists to prevent. Quote the **per-scope** yield tables\n(`_meta.scopes[].declared_types` / `zero_yield_types`), not only the\naggregate `_meta.declared_types`: a type can be non-zero org-wide while\nthe one scope that declared it yielded nothing, and only the per-scope\nrecord shows that. Convergence claims are bounded per scope — state\neach scope's declared surface, not one sentence about \"the manifest\".\nPlain facts; no \"100%\" claims beyond what the\ngates literally verified.\n\nThe report MUST include the verbatim stdout of both final gate runs —\nincluding the provenance stamp and every `input … sha256:` line.\nThose lines are what lets a reviewer tie the verdict to the exact tool\nbuild and the exact inputs; a report that paraphrases the gates instead\nof quoting them is not evidence.\n\n## Incremental runs\n\n- The workspace persists. `coverage.py`'s worklist is the only to-do\n  list; touch nothing that is already mapped.\n- Commit `.terraform.lock.hcl` in the (user-owned, private) workspace\n  repo: convergence verdicts are only reproducible against the exact\n  provider build that produced them.\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable. If a mapping must genuinely change (e.g. folder renamed),\n  emit `moved {}` blocks and flag it prominently in the report.\n- Manifest NARROWING fails closed: `coverage.py` flags now-out-of-scope\n  mapped keys as `stale?` problems and exits 2. Resolution is a human\n  decision: re-widen the manifest, sign waivers, or deliberately retire\n  the mappings (remove config + import blocks + coverage-map entries in\n  one reviewed commit) — never silently.\n- Manifest widening only ever adds worklist entries; it never invalidates\n  existing mappings.\n\n## Prerequisites\n\n`terraform` >= 1.5, `gcloud`, and [`uv`](https://docs.astral.sh/uv/).\n\nRun every script with `uv run scripts/<name>.py`. Each one declares its\nown dependencies inline (PEP 723), so nothing needs installing or\nactivating and the run cannot pick up a stale system PyYAML. Where `uv`\nis genuinely unavailable, `python3` >= 3.10 with PyYAML installed works\nand the arguments are identical — say which you used in the report.\n\nDiscovery IAM — grant the read-only role set from the README's \"Minimal\nRead-Only IAM Permissions\" table on the scope root. It covers org\nfoundation, folders, projects, networking, and storage; families\nverified later (KMS, CAS, VPC-SC, BigQuery, Pub/Sub, Tags, WIF) may\nneed additional per-service viewer roles — the plan gate names the\nmissing permission when one is needed.\n\n## References\n\n- [README.md](./README.md) — human user guide and architecture overview\n- [COVERAGE.md](./COVERAGE.md) — resource maturity matrix\n- [references/mapping-cookbook.md](./references/mapping-cookbook.md) —\n  Fabric mapping rules, escaping, import-ID table, known traps\n- [references/address-map.yaml](./references/address-map.yaml) — the\n  fielded subset of the cookbook (asset type, module, address, import\n  ID, verification round); validated and rendered by\n  `scripts/address_map.py`, never read by the gates\n- [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md)\n  — inferring import manifests from existing Terraform states\n- [references/manifest-migration.md](./references/manifest-migration.md)\n  — the manifest grammar changed to scopes-only with per-scope\n  `types:` lists; how to migrate a manifest written for the retired\n  grammar, and what stayed identical\n- [references/cai-blind-spots.md](./references/cai-blind-spots.md) —\n  where the CAI denominator is incomplete and what to do about it\n- [references/operating-contract.md](./references/operating-contract.md)\n  — invariants and trust boundaries in full\n","skills/fabric-importer/examples/import-manifest.fast-networking.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-networking: the\n# networking projects and the connectivity stack.\n#\n# Maturity: core VPC surface, DNS zones, Routers/NAT, and linked-VPC NCC\n# spokes are [V]; advanced policies and VPN are [C]/[N]. Scope to your\n# networking folder. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/333333333333         # networking folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: compute.googleapis.com/Network                   # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork                # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Route                     # [V] (dynamic routes waived)\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone                   # [V] modules/dns\n        levels: [project]\n      - type: dns.googleapis.com/Policy                        # [N] DNS server policies\n        levels: [project]\n      - type: dns.googleapis.com/ResponsePolicy                # [N]\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Hub           # [N] NCC hub\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Spoke         # [V] NCC linked-VPC spoke (raw fallback)\n        levels: [project]\n      - type: compute.googleapis.com/Router                    # [V] modules/net-cloudnat (router + NAT)\n        levels: [project]\n      - type: compute.googleapis.com/Address                   # [N] reserved addresses\n        levels: [project]\n      # - type: compute.googleapis.com/FirewallPolicy          # [C] net-firewall-policy\n      #   levels: [folder]\n      # - type: compute.googleapis.com/VpnGateway              # [C] net-vpn-*\n      #   levels: [project]\n      # - type: compute.googleapis.com/VpnTunnel               # [C]\n      #   levels: [project]\n      # - type: compute.googleapis.com/InterconnectAttachment  # [C] vlan attachments\n      #   levels: [project]\n","skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring the surface managed by FAST stage\n# 0-org-setup: org-level governance plus the IaC / billing-export /\n# observability projects, their service accounts, state buckets, tags, and WIF.\n#\n# Maturity legend (see COVERAGE.md): [V] live-verified  [C] cookbook\n# rules exist, not yet plan-tested  [N] new territory (fallback +\n# capability-gap report). Validate counts with `inventory.py collect`\n# before mapping; adjust the scope's include to your IaC folder/projects.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000   # <- your org id\n    # include/exclude match CAI `ancestors` (NUMERIC project ids).\n    # Restrict project-level types to the org-setup projects:\n    # include:\n    #   - projects/111111111111        # iac-core project NUMBER; see\n    #     import-manifest.multi-domain.yaml for selecting specific\n    #     projects per scope, and import-manifest.org-foundation.yaml\n    #     for the include-vs-exclude numeric-id trap\n    types:\n      - type: iam                                              # [V]\n        levels: [organization, project]\n      - type: org-policy                                       # [V]\n        levels: [organization]\n      - type: iam.googleapis.com/Role                          # [V] (org custom roles)\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink                   # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder       # [V]\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [organization, folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true          # impersonation grants enter the denominator\n      - type: logging.googleapis.com/LogBucket                 # [V]\n        levels: [project]\n      - type: storage.googleapis.com/Bucket                    # [V] state buckets\n        levels: [project]\n      - type: iam.googleapis.com/WorkloadIdentityPool          # [V] WIF federation\n        levels: [project]\n      - type: cloudresourcemanager.googleapis.com/TagKey       # [V] Resource Manager tags\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagValue     # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagBinding   # [V]\n        levels: [organization, folder, project]\n      # --- Optional additional types ---\n      # - type: cloudbilling.googleapis.com/BillingAccount     # [N] billing IAM\n      #   levels: [organization]\n","skills/fabric-importer/examples/import-manifest.fast-security.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-security: the security\n# projects plus KMS keyrings/keys and Certificate Authority Service (CAS).\n#\n# Maturity (see COVERAGE.md): [V] live-verified.\n# Note: KMS keyrings cannot be deleted in GCP; CAS pools bill monthly.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/222222222222         # security folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing                  # [V] modules/kms\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey                # [V] modules/kms\n        levels: [project]\n        iam: true          # per-key IAM is leaf IAM - opt in explicitly\n      - type: privateca.googleapis.com/CaPool                  # [V] modules/certificate-authority-service\n        levels: [project]\n      # - type: privateca.googleapis.com/CertificateAuthority  # [C]\n      #   levels: [project]\n","skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 1-vpcsc: the org access\n# policy, access levels, and service perimeters (modules/vpc-sc).\n#\n# Maturity: [V] live-verified (dry-run spec verified live; enforced\n# perimeter status block unverified). Access policies are org-level\n# singletons. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    types:\n      - type: identity.accesscontextmanager.googleapis.com/AccessPolicy      # [V]\n        levels: [organization]\n      - type: identity.accesscontextmanager.googleapis.com/AccessLevel       # [V]\n        levels: [organization, unknown]\n      - type: identity.accesscontextmanager.googleapis.com/ServicePerimeter  # [V]\n        levels: [organization, unknown]\n","skills/fabric-importer/examples/import-manifest.multi-domain.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"multi-domain\" — PER-SCOPE TYPES\n#\n# One estate, four different depths. Each scope carries its own `types:`\n# list — there is no other place to declare types, so what a scope\n# collects is exactly what is written on it. The same type may appear in\n# several scopes with different `levels`, `iam` or `enumerate`; that is\n# the point, not an accident.\n#\n# Fail-closed rules (all exit 1 with the reason, before any API call):\n#   - a scope without a `types:` list is refused;\n#   - `types: []` is refused: an empty list is a scope that collects\n#     nothing, and that is the one thing no scope may say quietly;\n#   - a type whose `levels` cannot intersect its scope's `levels` is\n#     refused as a dead declaration (an entry listing `unknown` is\n#     exempt — see import-manifest.fast-vpcsc.yaml);\n#   - a duplicate `type:` inside one scope's list is refused.\n#\n# Note the asymmetry with `emission:` (also per scope): emission INHERITS\n# built-in defaults for families you omit, because it only decides how\n# collected assets are written. `types:` never inherits anything, because\n# it decides what is collected at all.\n#\n# After collection, read the PER-SCOPE yield tables in inventory.json\n# (`_meta.scopes[].declared_types` / `zero_yield_types`): the aggregate\n# table can be non-zero for a type while one scope's own declaration\n# yielded nothing — e.g. `iam` non-empty org-wide but empty under\n# `workloads`. Every inventory entry also names the scope(s) that\n# collected it (`scopes: [...]`).\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Org foundation: governance only, no project-contained resources.\n  - name: org-foundation\n    root: organizations/000000000000        # <- your org id\n    levels: [organization, folder]\n    emission:\n      folder: factory                       # day-2 operating model\n    types:\n      - type: iam\n        levels: [organization, folder]\n      - type: org-policy\n        levels: [organization, folder]\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  # Networking folder: the connectivity stack, plus folder-level\n  # governance for that subtree only. Same `iam` and `org-policy` types\n  # as above, different levels — per-scope lists make that expressible.\n  - name: networking\n    root: folders/111111111111              # <- networking folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: compute.googleapis.com/Network\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork\n        levels: [project]\n      - type: compute.googleapis.com/Router\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone\n        levels: [project]\n\n  # Security folder: KMS and service accounts, with leaf IAM opted in\n  # per type. `iam: true` is a property of the type ENTRY, so it is\n  # per-scope too: another scope may collect the same CryptoKey type\n  # without leaf IAM.\n  - name: security\n    root: folders/222222222222              # <- security folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey\n        levels: [project]\n        iam: true\n\n  # Two workload projects, named explicitly. Project IAM is in scope\n  # here and nowhere else; no org-policy at all — a deliberate, declared\n  # gap, not a silent one.\n  - name: workloads\n    root: organizations/000000000000\n    levels: [project]\n    include:\n      - projects/333333333333               # app-prod  (project NUMBER)\n      - projects/444444444444               # app-dev\n    types:\n      - type: iam\n        levels: [project]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n\n# ---------------------------------------------------------------------------\n# `enumerate:` blocks live in the list that declares them: a natively\n# enumerated type is swept only in the scopes whose `types:` names it.\n# Repeat the block in every scope that needs it — see\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","skills/fabric-importer/examples/import-manifest.org-foundation.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"organization foundation\"\n#\n# This manifest reproduces exactly the surface the legacy (v1) pipeline\n# covered: org-level custom roles, org IAM (bindings + audit configs), org\n# policies, org log sinks, the folder hierarchy, and folder IAM.\n#\n# A manifest is the user-owned contract for a fabric-importer run:\n#   - inventory.py collects ONLY what the scopes declare (the completeness\n#     denominator becomes \"what you asked for\", not \"everything\");\n#   - coverage.py fails the run until every collected asset is mapped to a\n#     Terraform address or waived by a human;\n#   - re-runs are incremental: widen a scope's `types:` list (or let the\n#     org grow) and only the delta is added to the workspace. Existing\n#     addresses are never rewritten.\n#\n# The grammar is scopes-only: `scopes:` is a list, and EVERY scope carries\n# its own `types:` list. One estate, different depths per subtree = several\n# scopes, each with its own list — see import-manifest.multi-domain.yaml\n# for the worked multi-scope example and the fail-closed rules.\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Root of the import. organizations/N, folders/N and projects/N are all\n  # valid roots; everything below is evaluated inside this subtree.\n  - root: organizations/000000000000   # <- replace with your org id\n\n    # Optional: restrict to specific subtrees inside the root. An asset is\n    # in scope only if one of these appears among its ancestors. Omit to\n    # cover the whole root.\n    # include:\n    #   - folders/111111111111\n\n    # Optional: carve out subtrees. Exclusion wins over inclusion.\n    #\n    # NOTE (live-verified): include/exclude match against the CAI\n    # `ancestors` array, which stores NUMERIC ids. Both spellings work:\n    # `projects/123456789` (the project NUMBER) matches directly, and a\n    # project ID is resolved to its number by inventory.py via\n    # `gcloud projects describe`. Prefer the number where you know it —\n    # resolution needs the describe permission, and a project that cannot\n    # be resolved is reported as an enumeration failure rather than\n    # silently matching nothing.\n    # exclude:\n    #   - projects/123456789012\n\n    # Optional: emission style, per resource family. YOUR call — the\n    # scripts do not consume it; it directs step-3 mapping (the\n    # cookbook's \"Factory emission (opt-in)\" section is normative).\n    #   per-instance (default) : one module instance per container.\n    #   factory                : data-driven YAML via the family's\n    #                            Fabric factory carrier.\n    # An org foundation is the canonical factory case: folder tree,\n    # folder IAM and org policies maintained as hierarchical data/ YAML\n    # via project-factory.\n    #\n    # `emission` is per scope and INHERITS: a family key you omit gets\n    # the built-in default (per-instance; additive for iam). `types:`\n    # does NOT: every scope declares its full list, and an omission is\n    # an error, not inheritance. Types decide the denominator; emission\n    # only decides how collected assets are written. Do not reason from\n    # one knob to the other.\n    #\n    # emission:\n    #   folder: factory\n    #   iam: additive     # default — google_*_iam_member per tuple; an\n    #                     # apply can never strip unmanaged members. Use\n    #                     # `authoritative` to make IAM fully declarative\n\n    types:\n      # ---------------------------------------------------------------\n      # IAM policies (pseudo-type). CAI models IAM as a content type\n      # attached to resources, not as an asset type, hence the reserved\n      # name `iam`. `levels` controls WHERE bindings are replicated:\n      #   organization -> org IAM bindings + audit configs\n      #                   (including logging_data_access)\n      #   folder       -> folder IAM bindings\n      # Project-level IAM stays unmanaged in this example (add `project`\n      # to take it over). Only container-attached IAM is covered by this\n      # pseudo-type; IAM on leaf assets (tag values, buckets, service\n      # accounts, ...) needs an `iam: true` opt-in on the leaf type's\n      # own entry.\n      # ---------------------------------------------------------------\n      - type: iam\n        levels: [organization, folder]\n\n      # ---------------------------------------------------------------\n      # Org policies (pseudo-type; CAI content type org-policy). One\n      # inventory entry per (resource, constraint) pair, so coverage is\n      # per-constraint. This example covers org-level only; declaring\n      # [organization] makes the folder-level restriction explicit and\n      # honest: folder policies are out of scope BY DECLARATION rather\n      # than silently dropped.\n      # ---------------------------------------------------------------\n      - type: org-policy\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Custom IAM roles. Fabric target: modules/organization factory,\n      # data/custom-roles/<role_id>.yaml. Org-level only; project-level\n      # custom roles would need `project` here.\n      # ---------------------------------------------------------------\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Log sinks. Fabric target: modules/organization logging_sinks\n      # locals (with iam=false; _Default/_Required are quarantined).\n      # Folder and project sinks stay unmanaged here.\n      # ---------------------------------------------------------------\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Folder hierarchy. Fabric target: one modules/folder instance per\n      # folder (stable instance keys; parent chaining by module\n      # reference). `levels` refers to the CONTAINER of the asset: a\n      # folder whose parent is the org sits at level `organization`, a\n      # nested folder at `folder`.\n      # ---------------------------------------------------------------\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n# ---------------------------------------------------------------------------\n# What this manifest deliberately does NOT cover (would have been silent\n# gaps in v1; here they are visible declarations — add entries to the\n# scope's `types:` list to take them over):\n#\n#   - type: cloudresourcemanager.googleapis.com/Project\n#     levels: [organization, folder]\n#   - type: orgpolicy.googleapis.com/CustomConstraint\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagKey\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagValue\n#     levels: [organization]\n#   - type: essentialcontacts.googleapis.com/Contact\n#     levels: [organization]\n#   - type: compute.googleapis.com/Network        # modules/net-vpc\n#   - type: iam.googleapis.com/ServiceAccount     # modules/iam-service-account\n#     levels: [project]\n#     iam: true    # ALSO collect IAM attached to each SA (impersonation\n#                  # grants). Leaf-asset IAM never enters the denominator\n#                  # without this per-type opt-in.\n# ---------------------------------------------------------------------------\n\n# ---------------------------------------------------------------------------\n# TYPES CLOUD ASSET INVENTORY DOES NOT MODEL\n#\n# A type CAI cannot see would be invisible to BOTH gates at once, so it\n# is enumerated by other means and merged into the same inventory —\n# `gcloud` first. Declare it with an `enumerate:` block in the `types:`\n# list of every scope that needs it (per-scope lists never inherit):\n#\n#   - type: iam.googleapis.com/DenyPolicy      # not in the CAI catalogue\n#     levels: [organization, folder]\n#     enumerate:\n#       command: [iam, policies, list, --kind=denypolicies]\n#       container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n#       key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n#\n# Guard rails (read-only verbs only, no --filter/--limit, unique key\n# templates), the built-in enumerator table, and the full ladder (CAI ->\n# gcloud -> REST API -> signed waiver) are in\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","skills/fabric-importer/examples/waivers.example.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED waiver ledger (example).\n#\n# A waiver excludes one in-scope inventory key from the coverage gate:\n# \"we saw it, we decided not to manage it, here is why, signed\". The model\n# operating the skill MUST NOT add or edit waivers; it may only propose\n# them in its report for a human to review and commit.\n#\n# Every waiver requires key + reason. `signed_by` records who accepted\n# it — coverage.py reports waivers without it, and hard-fails them under\n# --require-signed-waivers (recommended whenever a human is in the\n# loop). Include the date in the signed_by value for auditing.\n\nwaivers:\n  - key: //cloudresourcemanager.googleapis.com/folders/000000000001\n    reason: >-\n      Decommissioning next quarter; not worth importing. Tracked in\n      TICKET-123.\n    signed_by: alice@example.com 2026-08-21\n\n  - key: //logging.googleapis.com/organizations/000000000000/sinks/tmp-debug\n    reason: temporary debugging sink owned by the SRE oncall; do not manage\n    signed_by: bob@example.com 2026-08-21\n","skills/fabric-importer/references/address-map.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Address and import-ID map — the FIELDED subset of the mapping cookbook.\n#\n# WHY THIS FILE EXISTS\n#\n# `mapping-cookbook.md` carries two different kinds of knowledge:\n#\n#   1. Fielded tuples — (asset type, module, address pattern, import ID,\n#      verification round). Uniform across every entry, mechanically\n#      checkable, and the part that kept being duplicated: the same\n#      address appeared once in the per-type prose and again in a\n#      hand-maintained markdown table, and three families were added\n#      twice because nothing could detect it.\n#\n#   2. Reasoning — ForceNew traps, provider quirks, waiver postures,\n#      capability gaps. Causal prose that becomes strictly worse when\n#      squeezed into a `notes:` string.\n#\n# Only (1) lives here. (2) stays in the cookbook, in prose, on purpose.\n# This file is NOT a specification of what the skill supports and NOT a\n# closed world: a type absent from it is a type nobody has run the\n# method on yet. See \"Step 3b\" in SKILL.md. Never treat a miss here as\n# a reason to stop, and never treat a hit here as a substitute for\n# reading the module source.\n#\n# NOT FROZEN. This file is advisory input to `address_map.py`, which is\n# a linter, not a gate. It is deliberately outside the trust boundary in\n# `integrity.py`: a wrong entry here makes `terraform plan` error\n# loudly, unlike a wrong `benign-drift.yaml` entry which silently passes\n# a gate. Different blast radius, different rigour budget.\n#\n# SCHEMA (enforced by `scripts/address_map.py --validate`)\n#\n#   version: 1\n#   entries:\n#     <slug>:                # stable, kebab-case, immutable once used\n#       resource:      str   # human label, used for the rendered table\n#       module:        str   # 'modules/<name>', or 'raw' for a gap\n#       address:       str   # Terraform address pattern, <placeholder>s\n#       import_id:     str   # provider import ID pattern\n#       asset_type:    str   # CAI asset type, or null when not CAI-modelled\n#       verified:      str   # 'r<N>' round that proved it, or false\n#       note:          str   # optional, ONE line; prose belongs in the cookbook\n#       disambiguation: str  # REQUIRED when an asset_type maps to >1 module\n#\n# INVARIANTS (all machine-enforced)\n#   - `address` is globally unique: no two entries may claim it.\n#   - when one `asset_type` maps to more than one `module`, EVERY entry\n#     sharing that type must carry a non-empty `disambiguation`.\n#   - `verified` is false or matches `r<digits>`.\n#   - `note` is a single line (multi-line reasoning goes in the cookbook).\n#\n# Regenerate the cookbook's rendered table after editing:\n#   uv run scripts/address_map.py --render-cookbook\n\nversion: 1\n\nentries:\n\n  # --- organization -------------------------------------------------\n\n  org-custom-role:\n    resource: Custom role\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_custom_role.roles[\"<id>\"]'\n    import_id: 'organizations/<org>/roles/<id>'\n    asset_type: iam.googleapis.com/Role\n    verified: r3\n\n  org-iam-member:\n    resource: Org IAM member (additive, default)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_member.bindings[\"<key>\"]'\n    import_id: '<org> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  org-iam-binding-authoritative:\n    resource: Org IAM binding (authoritative opt-in)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<org> <role>'\n    asset_type: null\n    verified: r3\n\n  org-iam-binding-conditional:\n    resource: Org IAM binding (cond.)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.bindings[\"<key>\"]'\n    import_id: '<org> <role> <condition_title>'\n    asset_type: null\n    verified: r3\n\n  org-audit-config:\n    resource: Org audit config\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_audit_config.default[\"<service>\"]'\n    import_id: '<org> <service>'\n    asset_type: null\n    verified: r3\n\n  org-policy:\n    resource: Org policy\n    module: modules/organization\n    address: 'module.organization.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'organizations/<org>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r5\n    disambiguation: 'org-level constraints; folder and project scopes have their own entries'\n\n  org-log-sink:\n    resource: Org log sink\n    module: modules/organization\n    address: 'module.organization.google_logging_organization_sink.sink[\"<name>\"]'\n    import_id: 'organizations/<org>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r3\n    disambiguation: 'org-level sinks; folder sinks are carried by modules/folder'\n\n  org-tag-key:\n    resource: Tag Key\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_key.default[\"<short_name>\"]'\n    import_id: 'tagKeys/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagKey\n    verified: r18\n\n  org-tag-value:\n    resource: Tag Value\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]'\n    import_id: 'tagValues/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagValue\n    verified: r18\n\n  tag-binding:\n    resource: Tag Binding\n    module: modules/organization\n    address: 'module.<instance>.google_tags_tag_binding.binding[\"<key>\"]'\n    import_id: 'tagBindings/<url-encoded-parent-and-value>'\n    asset_type: cloudresourcemanager.googleapis.com/TagBinding\n    verified: r18\n    note: 'emitted on the container module that owns the bound resource'\n\n  # --- folders ------------------------------------------------------\n\n  folder:\n    resource: Folder\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder.folder[0]'\n    import_id: 'folders/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/Folder\n    verified: r6\n\n  folder-iam-member:\n    resource: Folder IAM member (additive, default)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  folder-iam-binding-authoritative:\n    resource: Folder IAM\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'folders/<id> <role>'\n    asset_type: null\n    verified: r6\n\n  folder-iam-binding-conditional:\n    resource: Folder IAM (cond.)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <condition_title>'\n    asset_type: null\n    verified: r6\n\n  folder-org-policy:\n    resource: Folder org policy\n    module: modules/folder\n    address: 'module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'folders/<id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r6\n    disambiguation: 'folder-scoped constraints; org and project scopes have their own entries'\n\n  folder-log-sink:\n    resource: Folder sink\n    module: modules/folder\n    address: 'module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]'\n    import_id: 'folders/<id>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r6\n    disambiguation: 'folder-level sinks; org sinks are carried by modules/organization'\n\n  # --- projects -----------------------------------------------------\n\n  project:\n    resource: Project\n    module: modules/project\n    address: 'module.project-<key>.google_project.project[0]'\n    import_id: 'projects/<project_id>'\n    asset_type: cloudresourcemanager.googleapis.com/Project\n    verified: r4\n\n  project-service:\n    resource: Project service\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.project_services[\"<api>\"]'\n    import_id: '<project_id>/<api>'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-service-orgpolicy:\n    resource: Project service (orgpolicy)\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.org_policy_service[0]'\n    import_id: '<project_id>/orgpolicy.googleapis.com'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-iam-member:\n    resource: Project IAM member (additive, default)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_member.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  project-iam-binding-authoritative:\n    resource: Project IAM\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<project_id> <role>'\n    asset_type: null\n    verified: r4\n\n  project-iam-binding-conditional:\n    resource: Project IAM (cond.)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <condition_title>'\n    asset_type: null\n    verified: r4\n\n  project-org-policy:\n    resource: Project org policy\n    module: modules/project\n    address: 'module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'projects/<project_id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: false\n    disambiguation: 'project-scoped constraints; org and folder scopes have their own entries'\n\n  workload-identity-pool:\n    resource: Workload Identity Pool\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPool\n    verified: r18\n\n  workload-identity-pool-provider:\n    resource: Workload Identity Pool Provider\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPoolProvider\n    verified: r18\n\n  pam-entitlement:\n    resource: PAM entitlement\n    module: modules/project\n    address: 'module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]'\n    import_id: '<parent>/locations/<location>/entitlements/<entitlement_id>'\n    asset_type: privilegedaccessmanager.googleapis.com/Entitlement\n    verified: false\n    note: '<parent> is organizations/<org>, folders/<id> or projects/<project_id>; grants are excluded, never imported'\n\n  # --- service accounts ---------------------------------------------\n\n  service-account:\n    resource: Service account\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account.service_account[0]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email>'\n    asset_type: iam.googleapis.com/ServiceAccount\n    verified: r8\n\n  service-account-iam-member:\n    resource: SA IAM member (additive, default)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  service-account-iam-binding-authoritative:\n    resource: SA IAM binding\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role>'\n    asset_type: null\n    verified: r8\n\n  service-account-iam-binding-conditional:\n    resource: SA IAM binding (cond.)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <condition_title>'\n    asset_type: null\n    verified: r8\n\n  # --- logging and storage ------------------------------------------\n\n  log-bucket:\n    resource: Log bucket\n    module: modules/logging-bucket\n    address: 'module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]'\n    import_id: 'projects/<project_id>/locations/<location>/buckets/<name>'\n    asset_type: logging.googleapis.com/LogBucket\n    verified: r10\n\n  gcs-bucket:\n    resource: GCS bucket\n    module: modules/gcs\n    address: 'module.<instance>.google_storage_bucket.bucket[0]'\n    import_id: '<project_id>/<bucket_name>'\n    asset_type: storage.googleapis.com/Bucket\n    verified: r12\n    note: 'never import bare <bucket_name> — the project qualifier is a ForceNew trap'\n\n  bigquery-dataset:\n    resource: BigQuery Dataset\n    module: modules/bigquery-dataset\n    address: 'module.<instance>.google_bigquery_dataset.default'\n    import_id: 'projects/<project_id>/datasets/<dataset_id>'\n    asset_type: bigquery.googleapis.com/Dataset\n    verified: r17\n\n  pubsub-topic:\n    resource: Pub/Sub Topic\n    module: modules/pubsub\n    address: 'module.<instance>.google_pubsub_topic.default'\n    import_id: 'projects/<project_id>/topics/<topic_name>'\n    asset_type: pubsub.googleapis.com/Topic\n    verified: r17\n\n  # --- VPC networking -----------------------------------------------\n\n  vpc-network:\n    resource: VPC network\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_network.network[0]'\n    import_id: 'projects/<project_id>/global/networks/<name>'\n    asset_type: compute.googleapis.com/Network\n    verified: r3\n\n  vpc-subnet:\n    resource: VPC subnet\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r3\n    disambiguation: 'ordinary subnets; proxy-only and private-NAT subnets have their own entries'\n\n  vpc-subnet-proxy-only:\n    resource: VPC subnet (proxy-only)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r9\n    disambiguation: 'purpose REGIONAL_MANAGED_PROXY or GLOBAL_MANAGED_PROXY on the live subnet'\n\n  vpc-subnet-private-nat:\n    resource: VPC subnet (private NAT)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: false\n    disambiguation: 'purpose PRIVATE_NAT on the live subnet'\n\n  vpc-route-gateway:\n    resource: VPC route (gateway)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_gateway on the live route'\n\n  vpc-route-ilb:\n    resource: VPC route (ilb)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ilb on the live route'\n\n  vpc-route-instance:\n    resource: VPC route (instance)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_instance on the live route'\n\n  vpc-route-ip:\n    resource: VPC route (ip)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ip on the live route'\n\n  vpc-route-vpn-tunnel:\n    resource: VPC route (vpn tunnel)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_vpn_tunnel on the live route'\n\n  # --- VPC firewall rules (legacy) ----------------------------------\n\n  vpc-firewall-custom-rule:\n    resource: VPC firewall rule (custom)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/global/firewalls/<rule_name>'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'every rule not generated by the module default-rule toggles'\n\n  vpc-firewall-default-admins:\n    resource: VPC firewall rule (default, admins)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_admins[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-admins'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.admin_ranges, not by custom_rules'\n\n  vpc-firewall-default-http:\n    resource: VPC firewall rule (default, http)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_http[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.http_ranges, not by custom_rules'\n\n  vpc-firewall-default-https:\n    resource: VPC firewall rule (default, https)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_https[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.https_ranges, not by custom_rules'\n\n  vpc-firewall-default-ssh:\n    resource: VPC firewall rule (default, ssh)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_ssh[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.ssh_ranges, not by custom_rules'\n\n  # --- firewall policies --------------------------------------------\n\n  firewall-policy-hierarchical:\n    resource: Hierarchical Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_firewall_policy.hierarchical[0]'\n    import_id: 'locations/global/firewallPolicies/<id>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'org- or folder-attached policy'\n\n  firewall-policy-network-global:\n    resource: Global Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_network_firewall_policy.net_global[0]'\n    import_id: 'projects/<project_id>/global/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, global'\n\n  firewall-policy-network-regional:\n    resource: Regional Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]'\n    import_id: 'projects/<project_id>/regions/<region>/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, regional'\n\n  # --- compute addresses --------------------------------------------\n\n  address-global-psc:\n    resource: Compute address (global PSC)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psc[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'addressType INTERNAL with purpose PRIVATE_SERVICE_CONNECT'\n\n  address-global-psa:\n    resource: Compute address (global PSA)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psa[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose VPC_PEERING'\n\n  address-regional-internal:\n    resource: Compute address (regional internal)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_address.internal[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose GCE_ENDPOINT or SHARED_LOADBALANCER_VIP'\n\n  # --- routers, NAT, VPN, interconnect ------------------------------\n\n  cloud-router:\n    resource: Cloud Router\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'router whose only attached function is Cloud NAT; a router that also terminates VPN tunnels belongs to modules/net-vpn-ha'\n\n  cloud-nat:\n    resource: Cloud NAT\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router_nat.nat'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router>/<nat>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-ha-gateway:\n    resource: HA VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnGateways/<name>'\n    asset_type: compute.googleapis.com/VpnGateway\n    verified: false\n\n  vpn-external-gateway:\n    resource: External VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]'\n    import_id: 'projects/<project_id>/global/externalVpnGateways/<name>'\n    asset_type: compute.googleapis.com/ExternalVpnGateway\n    verified: false\n\n  vpn-tunnel:\n    resource: VPN Tunnel\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnTunnels/<name>'\n    asset_type: compute.googleapis.com/VpnTunnel\n    verified: false\n    note: 'shared_secret is ForceNew and unreadable — see the cookbook section before emitting'\n\n  vpn-router:\n    resource: Cloud Router (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'router that terminates VPN tunnels; a NAT-only router belongs to modules/net-cloudnat'\n\n  vpn-router-interface:\n    resource: Cloud Router interface (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]'\n    import_id: '<project_id>/<region>/<router_name>/<interface_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-router-peer:\n    resource: Cloud Router BGP peer (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  interconnect-attachment:\n    resource: Interconnect Attachment\n    module: modules/net-vlan-attachment\n    address: 'module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/interconnectAttachments/<name>'\n    asset_type: compute.googleapis.com/InterconnectAttachment\n    verified: false\n\n  # --- Cloud DNS ----------------------------------------------------\n\n  dns-managed-zone:\n    resource: DNS zone\n    module: modules/dns\n    address: 'module.<instance>.google_dns_managed_zone.dns_managed_zone[0]'\n    import_id: 'projects/<project_id>/managedZones/<zone>'\n    asset_type: dns.googleapis.com/ManagedZone\n    verified: r12\n\n  dns-record-set:\n    resource: DNS recordset\n    module: modules/dns\n    address: 'module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]'\n    import_id: 'projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>'\n    asset_type: dns.googleapis.com/ResourceRecordSet\n    verified: false\n    note: 'for_each key is \"<type> <name>\" — type first, single space, trailing dot on <name>'\n\n  dns-response-policy:\n    resource: DNS Response Policy\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy.default[0]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>'\n    asset_type: dns.googleapis.com/ResponsePolicy\n    verified: false\n\n  dns-response-policy-rule:\n    resource: DNS Response Policy rule\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>'\n    asset_type: dns.googleapis.com/ResponsePolicyRule\n    verified: false\n\n  # --- KMS ----------------------------------------------------------\n\n  kms-keyring:\n    resource: KMS KeyRing\n    module: modules/kms\n    address: 'module.<instance>.google_kms_key_ring.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>'\n    asset_type: cloudkms.googleapis.com/KeyRing\n    verified: r13\n\n  kms-crypto-key:\n    resource: KMS CryptoKey\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>'\n    asset_type: cloudkms.googleapis.com/CryptoKey\n    verified: r13\n\n  kms-crypto-key-iam:\n    resource: KMS CryptoKey IAM\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>'\n    asset_type: null\n    verified: r13\n\n  # --- Certificate Authority Service --------------------------------\n\n  cas-ca-pool:\n    resource: CAS CA Pool\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name>'\n    asset_type: privateca.googleapis.com/CaPool\n    verified: r14\n\n  cas-ca-pool-iam:\n    resource: CAS CA Pool IAM\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role>'\n    asset_type: null\n    verified: false\n\n  cas-ca-pool-iam-conditional:\n    resource: CAS CA Pool IAM (cond.)\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>'\n    asset_type: null\n    verified: false\n\n  cas-certificate-authority:\n    resource: Certificate Authority\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>'\n    asset_type: privateca.googleapis.com/CertificateAuthority\n    verified: false\n\n  # --- VPC Service Controls -----------------------------------------\n\n  vpcsc-access-policy:\n    resource: VPC-SC Access Policy\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_policy.default[0]'\n    import_id: '<policy_id>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessPolicy\n    verified: r15\n    note: 'import ID is the bare numeric policy id, with no accessPolicies/ prefix'\n\n  vpcsc-access-level:\n    resource: VPC-SC Access Level\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/accessLevels/<level_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessLevel\n    verified: r15\n\n  vpcsc-service-perimeter:\n    resource: VPC-SC Service Perimeter\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/ServicePerimeter\n    verified: r15\n\n  # --- Network Connectivity Center ----------------------------------\n\n  ncc-hub:\n    resource: NCC Hub\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_hub.hub[0]'\n    import_id: 'projects/<project_id>/locations/global/hubs/<hub_name>'\n    asset_type: networkconnectivity.googleapis.com/Hub\n    verified: false\n\n  ncc-spoke-ra:\n    resource: NCC spoke (router appliance)\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_spoke.spoke_ra'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: false\n    disambiguation: 'linked_router_appliance_instances spokes only'\n\n  ncc-spoke-raw:\n    resource: NCC spoke (raw — capability gap)\n    module: raw\n    address: 'google_network_connectivity_spoke.<instance>'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: r12\n    disambiguation: 'linked-VPC and linked-VPN spokes, which no Fabric module carries'\n","skills/fabric-importer/references/cai-blind-spots.md":"# CAI blind spots — where the denominator is incomplete\n\nThe completeness gate is only as strong as Cloud Asset Inventory's\ncoverage. An asset type CAI does not model is invisible to BOTH gates\nsimultaneously (not enumerated → not required by coverage; not emitted →\nnot diffed by plan). Treat this list as living documentation: verify and\nextend it per engagement.\n\nThere are three distinct blind spots, and they need different remedies:\n\n1. **CAI does not model the type at all.** The classic case, and what\n   most of this document is about. Remedy: enumerate it by other means\n   and merge it into the same denominator.\n2. **CAI models the type under a different NAME on the surface you\n   queried.** Rarer, much quieter, and not a blind spot in CAI at all —\n   a blind spot in the question. See\n   [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies).\n3. **CAI models the type but omits a FIELD a consumer depends on.**\n   Quietest of all: the sweep succeeds and the payload simply lacks the\n   attribute. See [Field-level blind spots](#field-level-blind-spots).\n\n## The rule\n\n**CAI is the DEFAULT source of the denominator, never the boundary of\nit.** Anything CAI does not support is retrieved by other means and\nmerged into the same denominator, so the completeness gate still has to\naccount for it. \"CAI cannot see it\" is not a reason for an asset to be\nabsent; it is a reason to enumerate it differently.\n\nThe ladder, in order of preference:\n\n1. **CAI** (`gcloud asset list`, `asset search-all-resources`) — default,\n   because one sweep covers a whole hierarchy and returns ancestry.\n2. **`gcloud <service> list|describe`** — the preferred alternative.\n   Read-only, already installed, already authenticated as the run's\n   read-only identity, and machine-readable with `--format=json`.\n   Declare it in the manifest as a native enumerator (below) so the\n   entries land in `inventory.json` like any others.\n3. **The service REST API** (`curl` with `gcloud auth print-access-token`)\n   — only where gcloud has no surface at all. Enumerate out of band,\n   record the exact call in the run report, and treat any assets found\n   as in scope for mapping.\n4. **A signed waiver plus a run-report entry** — the last resort, and a\n   deliberate, attributed decision. \"Not enumerable\" is a fact to\n   publish, never a silence.\n\n`inventory.py` walks steps 1→2 by itself. It ships a table of built-in\nenumerators (`NATIVE_ENUMERATORS`) for types known to be absent from the\nCAI catalogue: declaring such a type in the manifest is enough, and the\ntool announces that it took the gcloud route. A declared type that CAI\nrejects and no enumerator covers fails the run with the remedy, instead\nof a generic enumeration failure. Steps 3 and 4 it cannot enforce — that\nis the operator's honesty, and the run report is where it is spent.\n\n## Surface-dependent type taxonomies\n\nCAI does not have one asset-type taxonomy. It has two, and for a handful\nof Compute families they disagree:\n\n- the **list / export / query / monitor** surface (`gcloud asset list`,\n  this tool's primary sweep) splits a family by scope into **separate\n  asset types**;\n- the **search / analysis** surface (`gcloud asset\n  search-all-resources`) folds those same resources into a **single\n  unified type**.\n\nGoogle documents this per type on the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types),\nbut only in the per-type prose, which is easy to read past:\n\n> `compute.googleapis.com/Address` — Returns global and regional\n> addresses in the search and analysis APIs, and only regional addresses\n> in the list, export, query, and monitor APIs.\n>\n> `compute.googleapis.com/GlobalAddress` — Not available in the analysis\n> and search APIs. Use `compute.googleapis.com/Address` instead in the\n> search and analysis APIs.\n\nThe documented pairs:\n\n| Declared (search taxonomy) | List-surface sibling swept alongside it |\n|---|---|\n| `compute.googleapis.com/Address` | `compute.googleapis.com/GlobalAddress` |\n| `compute.googleapis.com/BackendService` | `compute.googleapis.com/RegionBackendService` |\n| `compute.googleapis.com/Disk` | `compute.googleapis.com/RegionDisk` |\n| `compute.googleapis.com/ForwardingRule` | `compute.googleapis.com/GlobalForwardingRule` |\n\n### Reproducing it, if you ever need to\n\nThe silent-gap condition needs a family whose declared type yields\n**non-zero** while still being incomplete: at least one REGIONAL address\nand at least one GLOBAL address, in the same in-scope project. With only\nglobal addresses present the declared type yields zero, the pre-existing\nzero-yield warning fires, and the tool is loud — a real bug, but not\nTHIS bug. (With several scopes, remember the aggregate warning can stay\nquiet while one scope's own declaration yielded nothing — the per-scope\n`_meta.scopes[].zero_yield_types` record is the loud one there.) A test estate built only from a freshly created global address\ntherefore validates that siblings are swept, and validates nothing about\nthe silence.\n\n### Why this one is worse than an unsupported type\n\nAn unsupported type fails loudly. This one fails **silently, past every\nguard the tool has** — which is why it needed a mechanism rather than a\nsharper error message. Live-run finding: a global Private Service\nConnect address never entered the denominator, because\n\n- the declared type IS supported by `asset list`, so the\n  unsupported-type fallback to `search-all-resources` never fired;\n- the sweep SUCCEEDED, so nothing landed in `SWEEP_FAILURES`;\n- it returned 33 regional addresses, so the zero-yield warning stayed\n  quiet;\n- the asset was never collected, so `apply_level_filter`'s `unknown`\n  safety net never saw it either.\n\nThe operator had already hand-written the correct `import {}` block, so\nthe only signal that fired was `coverage.py` reporting it as an **orphan\nimport block** — and an orphan was read as a coverage-map problem and\nwaived, rather than as evidence that the denominator was short.\n\n**An orphan import block for a resource you know is live is evidence the\ndenominator is short.** Investigate the enumeration before waiving it;\na waiver signed over a short denominator makes the gap permanent and\nattributed to a human.\n\n### What `inventory.py` does\n\n`CAI_SPLIT_TYPES` is a frozen table of the pairs above. Declaring the\nunified type sweeps its list-surface siblings too, and the siblings'\nentries are **retyped back to the declared type**, so one manifest line\nmeans \"all addresses\" — which is what an operator reading the\nsupported-types page will believe it means. This costs **zero extra API\ncalls**: `asset list` takes a comma-separated `--asset-types`, so the\nsiblings ride along in the existing sweep.\n\nNothing is laundered by the retyping:\n\n- each retyped entry carries `cai_list_type` with the type CAI actually\n  returned it as, and that field travels into `coverage.py`'s worklist;\n- `_meta.split_type_sweeps` records declared type, list-surface type,\n  scope and the RAW swept count — what CAI returned, before the\n  subtree/deleted/level filters;\n- collection prints a NOTICE naming every sibling that contributed, and\n  reconciles swept against in-the-denominator itself (e.g. `3 swept …,\n  2 in the denominator (1 excluded by scope/level/deleted filters)`) —\n  the filters apply to retyped entries like to any other asset, and the\n  subtraction is the tool's to show, not the reader's to derive.\n\nA sibling the manifest declares **in its own right** is not remapped: an\noperator who names `compute.googleapis.com/GlobalAddress` explicitly\nwants it accounted as itself, and remapping would make their\nper-declared-type yield read zero.\n\n### Checking the table against live CAI\n\nThe table is a frozen snapshot of a document Google changes. A new split\n— or a renamed pairing — would put the tool straight back into the\nfailure mode the table closes, so there is a probe:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra `search-all-resources` call per scope, restricted to declared\nsplit types. It compares against the **raw** sweep output, before the\nmanifest's subtree filters, because the question under test is taxonomy\nand not scope. Anything the search surface returns that the list sweep\ndid not is **fatal** (exit 3) with the offending resource names: the\ntable is stale and the denominator is incomplete.\n\nThe probe is opt-in, for two reasons: it costs a call per scope, and it\nimports the search index's propagation lag as a possible false positive.\nRun it at least once per engagement, and quote the result in the run\nreport. `_meta.split_parity` is **empty** when the probe did not run; a\nprobe that ran and found nothing is a RECORD whose `only_in_search` is\nempty. Read the record, not the key — \"not checked\" and \"checked, clean\"\nare different claims and must not be reported as the same one.\n\nIf the probe fires, identify the list-surface type of the missing assets\n(`gcloud asset list` with no `--asset-types`, then match on name) and\nreport it: `CAI_SPLIT_TYPES` needs a new entry, which is a reviewed\nchange to a frozen file, not a local patch.\n\nCAI **retiring** a split is the convergence this table is waiting for.\nA sibling type that `asset list` no longer recognises is therefore\nreported as a possibly-stale table, not as a failed run — a sibling is\ntool-supplied, not operator-declared, so it must never fail someone\nelse's collection.\n\n## Field-level blind spots\n\nA missing field in a CAI payload is asymmetric to an unsupported asset type:\n- An **unsupported type fails loudly**: CAI returns `INVALID_ARGUMENT`, the run\n  stops, and a remedy is displayed.\n- A **missing field fails silently**: `.get()` returns `None`, a filter\n  predicate answers \"no\", and the asset unexpectedly stays in or drops out\n  of the denominator while the run appears healthy.\n\nA filter predicate must never depend on a field whose presence in the live\npayload has not been verified. When a discriminator field is unavailable or\nunverified in CAI, the filter must **fail open** — keep the asset in the\ndenominator for a human to waive, never drop it silently.\n\n## Built-in enumerators\n\n| Asset type | How it is enumerated | Status |\n|---|---|---|\n| `iam.googleapis.com/DenyPolicy` | `gcloud iam policies list --kind=denypolicies --attachment-point=cloudresourcemanager.googleapis.com/<container>` | Command shape verified against gcloud 576; payload shape not yet exercised live |\n\nThe table is short on purpose. To qualify, an enumerator has to be\nhierarchy-container-scoped, read-only, JSON-emitting, and produce a key\nthat is stable and unique. Most non-CAI types fail one of those —\nusually the first — and stay in the blind-spot table below, enumerated\nby hand and named in the run report.\n\nThe table is frozen, like the rest of `inventory.py`: it decides part of\nwhat the denominator contains. Nothing about extending it requires\nediting a frozen file, though — a manifest block covers a type the table\ndoes not, and overrides an entry when an operator knows better. An\nenumerator earned in an engagement should come back as a reviewed change\nto the table, so the next run starts with it.\n\n## Declaring a native enumerator\n\nA manifest type entry may carry an `enumerate:` block. The type is then\nnever sent to CAI; the declared gcloud command runs once per in-scope\ncontainer at the type's `levels`, and its JSON becomes inventory\nentries:\n\n```yaml\n    types:   # a scope's own list — the only position types are declared in\n      # IAM deny policies: managed by modules/organization,\n      # modules/folder and modules/project, and absent from the CAI\n      # catalogue entirely.\n      - type: iam.googleapis.com/DenyPolicy\n        levels: [organization, folder]\n        enumerate:\n          # `gcloud` is implicit and is the only executable a manifest\n          # can name. The tool appends --format=json and one container\n          # argument.\n          command: [iam, policies, list, --kind=denypolicies]\n          # Optional: the shape of that container argument, when the\n          # command does not take --organization/--folder/--project=<id>.\n          # Fields: {container} (e.g. organizations/1) and\n          # {container_id} (1).\n          container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n          # Key template. Fields: {container}, {container_id} and\n          # {item.<dotted.path>} into each returned JSON object. Where\n          # the type has a CAI name format, mirror it, so entries from\n          # the two sources dedupe instead of double-counting.\n          key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n```\n\nDeclared in one scope's list, the command runs only for that scope's\ncontainers; another scope may declare the same type with a different\n`enumerate:` block. What it may not do is inherit: per-scope lists\nnever inherit anything, so the block is repeated in every scope that\nneeds it.\n\n**The mechanism is per hierarchy container**, because that is the shape\nof `levels` — the type entry's `levels` intersected with its scope's.\nAn empty intersection is refused when the manifest is parsed (a\nper-scope declaration that can never fire is a mistake, not a\nleftover), unless the entry lists `unknown`. A command that enumerates inside another\nresource — `gcloud storage managed-folders list gs://BUCKET` — cannot be\nexpressed as an `enumerate:` block: there is no container flag to fill\nin. Those types are enumerated out of band and named in the run report\n(step 3 of the ladder), or waived deliberately. Do not force them into a\nblock by hard-coding one bucket; a container argument that does not vary\nsweeps one place and leaves the rest silently unenumerated, which is why\nthe tool refuses it.\n\nRun the command by hand once before committing the block: the key\ntemplate has to match the payload the command actually returns, and a\nfirst run is the cheapest place to find out that it does not. A field\nthat is absent is a hard failure, and a yield of zero is reported\nloudly — neither is silent, but neither belongs in a run either.\n\nGuard rails, all fail-closed, all for the same reason — a native\nenumerator is a hand-written piece of the denominator, and the\ndenominator is the one thing in this skill nobody is allowed to shrink\nquietly:\n\n- the command must end in a read-only verb (`list`, `describe`, `get`,\n  `search`); anything else is refused before a single call is made;\n- `--format`, `--filter`, `--limit`, `--page-size`, `--flatten`,\n  `--sort-by` and `--uri` are refused. Narrowing belongs in the\n  manifest's `scope` block, where it is reviewed; a `--filter` inside an\n  enumerator shrinks the denominator with no trace;\n- the command may not select an identity — impersonation is set once for\n  the whole run via `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`;\n- a key template that renders the same key for two items, or that\n  references a field the payload does not have, is a hard failure. Two\n  assets collapsing onto one key is a shrunken denominator with a green\n  gate;\n- every native sweep is stamped into `inventory.json`'s `_meta`\n  (`native_sweeps`: type, verbatim command, container, yield) and\n  announced on stderr. A reviewer can re-run each command by hand.\n\nThe manifest is human-owned and committed at the scope-approval gate,\nwhich is what makes an operator-supplied command acceptable at all. An\nagent may draft one; a human signs it, exactly as with waivers.\n\n## Known or suspected gaps (verify per engagement)\n\n| Area | Concern | Mitigation |\n|---|---|---|\n| Service coverage | CAI supports several hundred asset types but not every GCP service/resource; niche or very new resources may be absent | Check the [CAI supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types) for every service the user cares about; for uncovered types, declare a native enumerator (above) so they still enter the denominator |\n| Wrong type string | A type string that does not exist in the catalogue is not a blind spot but a typo, and it presents as one. `gcloud asset list` answers `INVALID_ARGUMENT: No supported asset type matches: <type>` | `inventory.py` classifies that error separately from a permission failure and stops with instructions. Live example: `logging.googleapis.com/OrganizationSettings` does not exist — CAI models the Logs Router settings singleton as `logging.googleapis.com/Settings` (supported at `organization` and `folder` levels only; not indexed at `project` level) |\n| Right type string, wrong surface | A type that is correct for `search-all-resources` can cover only PART of its family under `asset list`, which splits some Compute families by scope into separate types. No error, non-zero yield, silent gap. Live example: a global PSC address typed `compute.googleapis.com/GlobalAddress` by the list surface, absent from a `compute.googleapis.com/Address` sweep | **Handled automatically** for the documented pairs — `CAI_SPLIT_TYPES` sweeps the siblings and accounts them under the declared type. Run `--verify-search-parity` once per engagement to check the frozen table against live CAI; see [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies) |\n| IAM deny policies | `iam.googleapis.com/DenyPolicy` is not a CAI asset type. Fabric manages deny policies at all three container levels (`iam_deny_policies`) | **Handled automatically** — built-in enumerator; just declare the type |\n| Log exclusions | `logging.googleapis.com/LogExclusion` is not a CAI asset type, and current `gcloud` has no `logging exclusions` group either — the ladder falls through to the REST API (`v2/{parent}/exclusions`). Fabric manages exclusions in `modules/organization`, `modules/folder` and `modules/project` | Enumerate out of band, map them, and record the enumeration method in the run report; do not let their absence from CAI read as absence from the estate |\n| Org-policy content-type lag | `--content-type=org-policy` can lag behind newly introduced v2 constraints; the `orgpolicy.googleapis.com/Policy` resource asset stream is more complete | `inventory.py` merges both CAI streams; keep cross-checking counts with `gcloud org-policies list` |\n| Org-policy dry-run specs | CAI `orgpolicy.googleapis.com/Policy` resource stream returns policies where `spec` is present (with or without `dryRunSpec`), but completely **omits dry-run-only policies** where `spec` is unset | `inventory.py collect` sweeps `gcloud org-policies list` per in-scope container and merges by key, so dry-run-only policies enter the denominator automatically |\n| IAM conditions | The `iam-policy` content type returns version-3 policies with conditional bindings intact | Verified in live testing |\n| IAM on leaf assets | `inventory.py` restricts the `iam` pseudo-type to container assets (`Organization`, `Folder`, `Project`); leaf IAM needs explicit per-type manifest entries (`iam: true`) | Documented in the manifest reference |\n| Audit configs | `auditConfigs` block is present and fully preserved in the CAI `iam-policy` payload | Verified in live testing |\n| Deleted / pending-delete resources | CAI reflects live state; soft-deleted roles or pending-delete projects may or may not appear | Decide policy per type; document in the run report |\n| Propagation lag | CAI can lag live changes by minutes | Re-run inventory immediately before the final gate pass; treat count mismatches with service APIs as failures, not noise |\n| Cloud Storage below the bucket | The Cloud Storage section of the CAI catalogue contains exactly one type, `storage.googleapis.com/Bucket`. Objects (`google_storage_bucket_object`), managed folders (`google_storage_managed_folder`) and managed-folder IAM are not modelled | `gcloud storage ls` / `gcloud storage managed-folders list` take a bucket URL, not a container flag, so they do not fit an `enumerate:` block. Enumerate per bucket out of band, map, and record the method in the run report |\n| Terraform-only resources | Some `google_*` resources correspond to no asset at all. `google_project_service_identity` triggers creation of a Google-managed service agent: the state row is real, the CAI asset is not, and Fabric's `modules/project` handles service agents internally | Map through the owning module and note it in the report; there is nothing to enumerate and nothing to waive |\n| Leaf IAM read as a missing type | `google_storage_bucket_iam_binding`, `google_tags_tag_value_iam_binding` and friends are not asset types. They are the `iam-policy` content type on an asset CAI already models, and enter the denominator via `iam: true` on the PARENT type entry | Never waive these as unsupported. `manifest_from_state.py` maps the common ones to their parent with the flag already set |\n| Data-plane / child resources | Some child resources (e.g. per-bucket notification configs, dataset ACL entries) are attributes of the parent in CAI, not assets | The plan gate covers them once the parent is imported; note them in the report if the user expects per-child coverage |\n| Access Context Manager | CAI `asset list --content-type=resource` rejects ACM types, requiring `asset search-all-resources`; `parentFullResourceName` lacks standard container prefixes | Handled in `inventory.py` via dedicated search and level classification |\n\n## Rules\n\n1. When a scope's `types:` list declares a type, confirm it appears in\n   the CAI supported-types list. If it does not, it gets a native enumerator, an\n   out-of-band enumeration recorded in the report, or a signed waiver —\n   one of the three, always. Never a quiet removal from the manifest.\n2. Any count mismatch between CAI and a service API is a **failure to\n   investigate**, never noise to average over. The same applies between\n   CAI's own two surfaces: `asset list` and `search-all-resources`\n   disagreeing about a type is a taxonomy split to identify, not a\n   discrepancy to pick a winner from.\n3. An **orphan import block** reported by `coverage.py` for a resource\n   you know is live is evidence the denominator is short. Investigate\n   the enumeration first; a waiver over a short denominator makes the\n   gap permanent and signs a human's name to it.\n4. Every entry that did not come from CAI is named in the run report,\n   with the command that produced it. `_meta.native_sweeps` in\n   `inventory.json` carries this for declared enumerators;\n   `_meta.split_type_sweeps` carries entries that came from CAI under a\n   different type than the one declared; out-of-band enumeration is on\n   you to quote. Per-scope yields live in\n   `_meta.scopes[].declared_types` / `zero_yield_types` — the aggregate\n   table can hide a scope whose own declaration yielded nothing.\n5. A scope's `types:` list narrows the denominator, so it belongs to\n   the Scope Approval gate like any other narrowing. A convergence\n   claim is bounded per scope, not per manifest — report it that way.\n6. New blind spots discovered during an engagement get added here (this\n   file is reference documentation, not a frozen tool).\n","skills/fabric-importer/references/inferring-manifests-from-state.md":"# Inferring Import Manifests from Terraform State\n\nWhen migrating or refactoring existing Google Cloud infrastructure managed by Terraform into Cloud Foundation Fabric (CFF), inferring the `import-manifest.yaml` directly from existing Terraform state file(s) (`.tfstate`) is the most precise and deterministic way to define the import boundary.\n\n---\n\n## Why State-Driven Manifest Generation?\n\n| Approach | Strength | Trade-off / Note |\n| :--- | :--- | :--- |\n| **State-Driven Inference (`manifest_from_state.py`)** | Exactly matches the existing Terraform management footprint. No risk of accidentally pulling unmanaged live assets into the stage scope. | Requires access to state files (`terraform state pull` or GCS bucket access). |\n| **Live Asset Survey (`inventory.py survey` + `manifest_init.py`)** | Discovers all live cloud assets, including unmanaged or out-of-band resources. | Broader denominator; requires manually waiving pre-existing or external assets. |\n\n---\n\n## Workflow\n\n### 1. Retrieve the Terraform State(s)\n\nPull the state files for the target stages or root modules:\n\n```bash\n# Example for FAST Stage 0\ngsutil cp gs://<iac-core-project>-iac-org-state/default.tfstate stage-0.tfstate\n\n# Or pull via Terraform CLI\nterraform -chdir=path/to/stage-0 state pull > stage-0.tfstate\n```\n\nFor multi-stage environments (e.g., FAST stages 0, 1, 2), you can pull states for all relevant stages:\n```bash\nterraform -chdir=fast/stages/0-org-setup state pull > stage-0.tfstate\nterraform -chdir=fast/stages/1-vpcsc state pull > stage-1.tfstate\nterraform -chdir=fast/stages/2-networking state pull > stage-2-networking.tfstate\nterraform -chdir=fast/stages/2-security state pull > stage-2-security.tfstate\n```\n\n### 2. Generate the Manifest\n\nRun `manifest_from_state.py`:\n\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate \\\n  --out import-manifest.yaml\n```\n\nTo synthesize a manifest across multiple stages (note `--force`: the\nmanifest is human-owned and gate-relevant, so it is never overwritten\nsilently — use `--out -` to review on stdout first):\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate stage-1.tfstate stage-2-networking.tfstate stage-2-security.tfstate \\\n  --out import-manifest.yaml --force\n```\n\nAll state files must belong to the same organization: a state spanning\nseveral is refused rather than silently resolved to one of them.\n\nWhen running on an isolated stage state (e.g. `stage-2-networking.tfstate`) that contains resources across multiple folders without including an organization resource in that state, specify the intended scope root with `--root`:\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-2-networking.tfstate \\\n  --root organizations/123456789012 \\\n  --out import-manifest.yaml\n```\n\n### 3. Review and Collect\n\nReview the generated `import-manifest.yaml`, then collect the live denominator:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\n---\n\n## Type & Level Mapping Reference\n\nThe inference script automatically maps Terraform `google_*` resources to CAI asset types and container levels:\n\n| Terraform Resource Type | Manifest `type` | Container Levels | Special Handling |\n| :--- | :--- | :--- | :--- |\n| `google_organization_iam_*` | `iam` | `[organization]` | Authoritative & additive bindings |\n| `google_folder_iam_*` | `iam` | `[folder]` | Folder IAM |\n| `google_project_iam_*` | `iam` | `[project]` | Project IAM |\n| `google_service_account_iam_*` | `iam.googleapis.com/ServiceAccount` | `[project]` | Emits `iam: true` on SA type |\n| `google_org_policy_policy`, `google_organization_policy` | `org-policy` | `[organization, folder, project]` | Inferred from parent attribute |\n| `google_organization_iam_custom_role` | `iam.googleapis.com/Role` | `[organization]` | Org custom roles |\n| `google_project_iam_custom_role` | `iam.googleapis.com/Role` | `[project]` | Project custom roles |\n| `google_logging_organization_sink` | `logging.googleapis.com/LogSink` | `[organization]` | Org log sinks |\n| `google_logging_folder_sink` | `logging.googleapis.com/LogSink` | `[folder]` | Folder log sinks |\n| `google_logging_project_sink` | `logging.googleapis.com/LogSink` | `[project]` | Project log sinks |\n| `google_logging_project_bucket_config`| `logging.googleapis.com/LogBucket` | `[project]` | Project log buckets |\n| `google_logging_organization_settings` | `logging.googleapis.com/Settings` | `[organization]` | Logs Router settings singleton (CAI has no `OrganizationSettings` type) |\n| `google_logging_folder_settings` | `logging.googleapis.com/Settings` | `[folder]` | Logs Router settings singleton |\n| `google_essential_contacts_contact` | `essentialcontacts.googleapis.com/Contact` | inferred from `parent` | Essential contacts |\n| `google_storage_bucket_iam_*` | `storage.googleapis.com/Bucket` | `[project]` | Emits `iam: true` on the bucket type |\n| `google_tags_tag_value_iam_*` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Emits `iam: true` on the tag value type |\n| `google_folder` | `cloudresourcemanager.googleapis.com/Folder` | `[organization, folder]` | Top-level and nested folders |\n| `google_project` | `cloudresourcemanager.googleapis.com/Project` | `[organization, folder]` | Projects |\n| `google_storage_bucket` | `storage.googleapis.com/Bucket` | `[project]` | GCS Buckets |\n| `google_service_account` | `iam.googleapis.com/ServiceAccount` | `[project]` | Service Accounts |\n| `google_tags_tag_key` | `cloudresourcemanager.googleapis.com/TagKey` | `[organization]` | Resource Manager Tag Keys |\n| `google_tags_tag_value` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Tag Values |\n| `google_tags_tag_binding` | `cloudresourcemanager.googleapis.com/TagBinding` | `[organization, folder, project]` | Tag Bindings |\n| `google_project_service` | `serviceusage.googleapis.com/Service` | `[project]` | Enabled APIs |\n| `google_access_context_manager_*` | `identity.accesscontextmanager.googleapis.com/*` | `[organization]` | Access Policies, Perimeters, Levels |\n| `google_compute_network` | `compute.googleapis.com/Network` | `[project]` | VPCs |\n| `google_compute_subnetwork` | `compute.googleapis.com/Subnetwork` | `[project]` | Subnets |\n| `google_compute_global_address` | `compute.googleapis.com/Address` | `[project]` | Global IP Addresses |\n| `google_compute_router` | `compute.googleapis.com/Router` | `[project]` | Cloud Routers & NAT |\n| `google_compute_firewall` | `compute.googleapis.com/Firewall` | `[project]` | Firewall rules |\n| `google_compute_firewall_policy` | `compute.googleapis.com/FirewallPolicy` | `[organization, folder]` | Hierarchical Firewall Policies |\n| `google_compute_network_firewall_policy` | `compute.googleapis.com/NetworkFirewallPolicy` | `[project]` | Network Firewall Policies |\n| `google_compute_ha_vpn_gateway`, `google_compute_vpn_gateway` | `compute.googleapis.com/VpnGateway` | `[project]` | HA & Classic VPN Gateways |\n| `google_compute_external_vpn_gateway` | `compute.googleapis.com/ExternalVpnGateway` | `[project]` | External VPN Gateways |\n| `google_compute_vpn_tunnel` | `compute.googleapis.com/VpnTunnel` | `[project]` | VPN Tunnels |\n| `google_dns_managed_zone` | `dns.googleapis.com/ManagedZone` | `[project]` | Cloud DNS Zones |\n| `google_dns_response_policy` | `dns.googleapis.com/ResponsePolicy` | `[project]` | Cloud DNS Response Policies |\n| `google_network_connectivity_hub` | `networkconnectivity.googleapis.com/Hub` | `[project]` | NCC Hubs |\n| `google_network_connectivity_spoke` | `networkconnectivity.googleapis.com/Spoke` | `[project]` | NCC Spokes |\n| `google_storage_managed_folder` | `storage.googleapis.com/ManagedFolder` | `[project]` | Storage Managed Folders |\n| `google_storage_managed_folder_iam_*` | `storage.googleapis.com/ManagedFolder` | `[project]` | Emits `iam: true` on ManagedFolder |\n| `google_kms_key_ring` | `cloudkms.googleapis.com/KeyRing` | `[project]` | KMS Key Rings |\n| `google_kms_crypto_key` | `cloudkms.googleapis.com/CryptoKey` | `[project]` | KMS Keys |\n| `google_bigquery_dataset` | `bigquery.googleapis.com/Dataset` | `[project]` | BigQuery Datasets |\n| `google_pubsub_topic` | `pubsub.googleapis.com/Topic` | `[project]` | Pub/Sub Topics |\n| `google_pubsub_subscription` | `pubsub.googleapis.com/Subscription` | `[project]` | Pub/Sub Subscriptions |\n| `google_secret_manager_secret` | `secretmanager.googleapis.com/Secret` | `[project]` | Secret Manager |\n| `google_iam_workload_identity_pool` | `iam.googleapis.com/WorkloadIdentityPool` | `[project]` | WIF Pools |\n| `google_iam_workload_identity_pool_provider` | `iam.googleapis.com/WorkloadIdentityPoolProvider` | `[project]` | WIF Identity Providers |\n\n### Types this table does not list\n\nA `google_*` type absent from the table is reported as unmapped, and the\nwarning is precise about what that means: **this tool has no static\nTerraform-to-CAI row for it**. It is not a statement about Cloud Asset\nInventory. Triage each one — CAI may model it (add the type by hand), it\nmay be leaf IAM on a type CAI models (`iam: true` on the parent), CAI may\ngenuinely not model it (`enumerate:` block or out-of-band enumeration,\nsee [cai-blind-spots.md](./cai-blind-spots.md)), or it may be deliberately\nout of scope (signed waiver). Anything you resolve is worth sending back\nas a `TF_TYPE_MAP` entry.\n\n---\n\n## Multi-Scope Partitioning\n\nWhen resources span both Organization/Folder governance and project-contained resources, `manifest_from_state.py` produces a multi-scope configuration:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: stage-projects\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111   # prj-prod-audit-logs-0\n      - projects/222222222222   # prj-prod-iac-core-0\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n```\n\nThis prevents project-level queries from accidentally scanning every unrelated project in the organization while still managing org-level policies and folders.\n\nEvery scope carries its own `types:` list; the generator filters the inferred types per scope (a type whose `levels` cannot fire at a scope's levels is left out, because `inventory.py` refuses dead per-scope declarations) but keeps each entry's full inferred `levels` — the intersection with the scope's `levels` happens at collect time, so the entry reads the same in every scope that carries it. Narrowing a generated list further — dropping a type or a level from one scope but not another — is a normal human refinement of the draft. Regenerating the manifest discards such refinements, so refine after generation, not before.\n","skills/fabric-importer/references/manifest-migration.md":"# Manifest migration — from the retired grammar to scopes-only\n\nThe import-manifest grammar changed once, and this file is the record of\nit. If `inventory.py` refused your manifest with *\"manifest declares\nretired top-level key(s)\"*, you are holding a manifest written for the\nold grammar; this page is the complete migration path.\n\n## What changed, and why\n\n**Old grammar (retired).** A manifest declared WHERE to look and WHAT to\ncollect in two separate places: a singular `scope:` (or a `scopes:`\nlist) for the subtrees, and one manifest-global `types:` list shared by\nevery scope. The only per-scope discrimination was the `levels` axis\n(organization / folder / project / unknown): a type declared anywhere\nwas swept in every scope whose levels intersected its own.\n\n**New grammar (current).** `scopes:` is the only form, and **every scope\ncarries its own `types:` list**. There is no top-level `types:` and no\nsingular `scope:`. What a scope collects is exactly what is written on\nit.\n\nThe motivation is per-scope type declarations — different resource\ntypes under different subtrees (org governance at org/folder level, the\nconnectivity stack under the networking folder, project IAM only in\nnamed workload projects), which the global list could not express\nwithout waiving every out-of-domain asset by hand. Making the new form\nthe ONLY form, rather than an option next to the old one, makes three\nwhole classes of silent-denominator failure unrepresentable instead of\nmerely validated: there is no inheritance to half-apply, no global list\nto go stale behind the scopes, and no mixed manifest whose meaning\ndepends on which scope the reviewer happened to read.\n\nThe tool refuses the old grammar rather than silently accepting half of\nit, because a half-read manifest would change what the denominator\nmeans — the exact failure the gates exist to prevent.\n\n## Migration: singular `scope:`\n\nWrap the scope as a one-element `scopes:` list and move the `types:`\nlist inside it (indent by four spaces). Nothing else changes — same\ntypes, same levels, same include/exclude, same collected denominator.\n\n```yaml\n# OLD (refused)                     # NEW\nscope:                              scopes:\n  root: organizations/123            - root: organizations/123\n  include:                             include:\n    - folders/456                        - folders/456\ntypes:                                 types:\n  - type: org-policy                     - type: org-policy\n    levels: [organization]                 levels: [organization]\n  - type: iam                            - type: iam\n    levels: [organization, folder]         levels: [organization, folder]\n```\n\n## Migration: `scopes:` list with a shared top-level `types:`\n\nCopy the top-level list into EVERY scope, then (optionally, later)\nnarrow each copy. The unnarrowed copy is semantically identical to the\nold manifest: the collect-time intersection of a type's `levels` with\neach scope's `levels` is unchanged, so the same assets enter the\ndenominator.\n\n```yaml\n# OLD (refused)\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\ntypes:\n  - type: iam\n    levels: [organization, folder, project]\n  - type: cloudresourcemanager.googleapis.com/Folder\n    levels: [organization, folder]\n\n# NEW — verbatim copy first, narrow later\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n```\n\nNote the second scope: `cloudresourcemanager.googleapis.com/Folder`\nwas NOT copied into it. A per-scope entry whose `levels` cannot\nintersect its scope's `levels` is refused as a dead declaration\n(`[organization, folder] ∩ [project] = ∅`), so a verbatim copy is only\nvalid where the type could actually fire. Under the old grammar that\nentry was silently inactive for the scope; now it is an error — drop\nit from the scopes it cannot fire in. Entries whose `levels` include\n`unknown` are exempt and may be copied everywhere.\n\nIf YAML repetition bothers you, anchors keep shared lists in one\nplace:\n\n```yaml\nscopes:\n  - name: teams\n    root: organizations/123\n    include: [folders/111]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: rest\n    root: organizations/123\n    exclude: [folders/111]\n    types: *folder-types\n```\n\n## Key mappings, old → new\n\n| Old | New |\n|---|---|\n| `scope:` (singular) | `scopes:` with one entry |\n| top-level `types:` | each scope's own `types:` (copy, then narrow) |\n| top-level `emission:` (manifest-wide default) | `scopes[].emission` on each scope that wants a non-default style; an omitted family falls back to the built-in default (`per-instance`; `additive` for `iam`), not to another scope |\n| `enumerate:` block, declared once | declared in the `types:` list of every scope that needs the sweep — per-scope lists never inherit, so the block is repeated per scope (a different scope may even carry a different block for the same type) |\n| `iam: true` leaf opt-in, declared once | per scope, like everything else on a type entry: a scope that omits it collects the resource without its leaf IAM |\n| type silently inactive in a scope (empty levels intersection) | refused as a dead declaration (drop the entry from that scope; `unknown` entries exempt) |\n\n## What did NOT change\n\n- The type entry schema: `type`, `levels`, `iam`, `enumerate` are\n  unchanged, including all `enumerate:` guard rails.\n- Scope fields: `name`, `root`, `include`, `exclude`, `levels` are\n  unchanged, including numeric-id resolution for include/exclude.\n- Collect-time semantics for a faithful copy: same sweeps, same\n  intersection rule, same deduplicated denominator. A migrated\n  manifest that copies the old list into every (compatible) scope\n  yields the same `assets` — diffing old and new `inventory.json`\n  asset keys is the cheap proof.\n- Gates and waivers: `coverage.py` and `verify_plan.py` are untouched;\n  existing workspaces, coverage maps and waiver ledgers keep working.\n\n## New output you should start reading\n\n- `_meta.scopes[].declared_types` / `zero_yield_types` — per-scope\n  yield tables. The aggregate `_meta.declared_types` cannot show a type\n  that yields zero in one scope while non-zero in another; the\n  per-scope record (and its stderr warning) can. Quote the per-scope\n  tables in the step-5 report.\n- Every inventory entry carries `scopes: [...]` — the scope(s) that\n  collected it, merged and sorted when scopes overlap.\n\n## Migration checklist\n\n1. Rewrite the manifest as above (or regenerate:\n   `manifest_from_state.py` and `manifest_init.py` emit the new\n   grammar directly).\n2. Run `inventory.py collect` — validation is fail-closed and runs\n   before any API call, so a wrong migration is a refused manifest\n   with the reason, never a shrunken denominator.\n3. Diff the new `inventory.json` asset keys against the last\n   pre-migration one. Identical keys = faithful migration. Missing\n   keys = you narrowed while migrating; deliberate narrowing belongs\n   in its own reviewed manifest change, not folded into the migration.\n4. The manifest is human-owned: the migrated file goes through the\n   same Scope Approval gate as any other manifest edit.\n\nWorked references: `examples/import-manifest.org-foundation.yaml`\n(single scope) and `examples/import-manifest.multi-domain.yaml` (four\nscopes, per-scope types). The fail-closed rules live in SKILL.md\nstep 0 and the cookbook's \"Per-scope type declaration\" subsection.\n","skills/fabric-importer/references/mapping-cookbook.md":"# Mapping cookbook — live GCP to Cloud Foundation Fabric\n\nReview-hardened knowledge from the live verification rounds summarised in\n[COVERAGE.md](../COVERAGE.md). Every rule here encodes a bug that was\nactually made and caught; provenance markers like \"(verified r9)\" point\nat the round that proved the rule. Read the section for a resource type\nBEFORE mapping it. When in doubt, read the Fabric module source in this\nrepository — never trust memory for module internals.\n\n**This document is deliberately incomplete, and always will be.** It is\nnot a specification of what the skill supports; it is the precipitate of\na method — the accumulated residue of mapping real resources against a\nlive organization and keeping what survived the gates. A type with no\nsection here is not unsupported. It means nobody has run the method on it\nyet.\n\nSo entries are born the same way every time, and that is the only way\nthey should be born: someone maps a live resource by reading the module\nsource, converges both gates, and writes down what cost them a cycle. See\n\"Step 3b — when the cookbook has never seen this type\" in\n[SKILL.md](../SKILL.md) for the loop. **If you map a type that has no\nsection here, adding one is part of your run report** — that is what\nkeeps this file growing rather than the same traps being paid for on\nevery engagement.\n\nWhat belongs in an entry: the CAI asset-type string as live output\nreturns it, the module addresses and `for_each` key formats, the import\nID format, the ForceNew inputs that must mirror live values, and every\ntrap that cost a cycle. Mark surfaces you did not exercise as unverified\n— an honest gap is worth more than an implied guarantee.\n\nContents: canonical module map · factory emission (opt-in) · universal\nrules · workspace layout · scaffolding scripts · import-ID table ·\nper-type rules (organization, folders, projects, service accounts, log\nsinks, logging buckets, VPC networks, root module) · fallback and\naccelerator.\n\n**Where the fielded data lives.** Address patterns, import IDs, CAI\nasset types and verification rounds are held in\n[`address-map.yaml`](./address-map.yaml) and rendered into the\nimport-ID table below; the prose here owns everything else — the traps,\nthe causal reasoning and the capability gaps. Add a new address to the\nYAML, not to the table, and run\n`uv run scripts/address_map.py` to check both.\n\n## Canonical module map (normative)\n\nFabric modules are the product — this table is the default mapping, not\na suggestion. Raw resources require a documented module capability gap\n(see \"Fallback\" at the end).\n\nEmission has two modes — `per-instance` (the default: one module\ninstance per container, inputs as HCL) and `factory` (data-driven YAML\nconsumed by a Fabric factory carrier). The mode is chosen per resource\nfamily in the manifest's `emission:` block (see \"Factory emission\n(opt-in)\" below); families with `—` in the factory column have exactly\none mode and ignore the knob. Some sub-resources are factory-only by\nFabric convention regardless of the knob: org policies, custom roles\nand custom constraints are carried as `data/` YAML inside their owning\n`organization` / `folder` / `project` instance.\n\n| Scope / resource family | Default (`per-instance`) | `factory` emission — carrier | Carries |\n|---|---|---|---|\n| Organization | `modules/organization` (single instance) | — | org IAM + audit configs, org policies (factory), custom roles (factory), org sinks |\n| Folder | `modules/folder` — one instance per folder | `project-factory` — hierarchy `data/`, one YAML dir per folder, path = key | the folder itself, folder IAM, folder org policies (factory), folder sinks |\n| Project | `modules/project` — one instance per project | `project-factory` — one project YAML per project | the project, services, project IAM, project org policies, metadata |\n| Service accounts | `modules/iam-service-account` | `project-factory` — `service_accounts:` block in the owning project's YAML (only when that project is factory-emitted) | SA + its IAM |\n| Log buckets | `modules/logging-bucket` | — | bucket config (sink destinations) |\n| VPC networks | `modules/net-vpc` | subnets only: `net-vpc` `factories_config.subnets_folder` | network, subnets (incl. proxy-only), routes |\n| VPC firewall rules (legacy) | `modules/net-vpc-firewall` | `net-vpc-firewall` `factories_config.rules_folder` | custom rules + the four `default_rules_config` rules |\n| Cloud NAT & Routers | `modules/net-cloudnat` | — | Cloud NAT + its Cloud Router (NAT-only routers) |\n| HA VPN | `modules/net-vpn-ha` | — | HA VPN gateway, external gateway, tunnels, its own Cloud Router, interfaces, BGP peers |\n| Cloud DNS | `modules/dns` | — (no factory: recordsets are the `recordsets` input, keyed `\"<type> <name>\"`) | managed zones, recordsets |\n| DNS Response Policy | `modules/dns-response-policy` | `dns-response-policy` `factories_config.rules` | response policies & rules |\n| NCC | `modules/ncc-spoke-ra` (hub + router-appliance spokes only) | — | hub; linked-VPC/VPN spokes are a capability gap |\n| Firewall policy rules | `modules/net-firewall-policy` inputs | `net-firewall-policy` `factories_config` rule files | ingress/egress rules |\n| Other | nearest Fabric module for the family; check `modules/` in-repo | see `FACTORIES.md` at the repo root — the authoritative factory catalog (every module factory flows through `factories_config`) | — |\n\nInstance keys: pick short stable slugs at first import (record them in\n`coverage-map.yaml`); keys are immutable afterwards. Because the key —\nnot the display name — defines the address, a console rename plans as\nan in-place `name` update, not destroy/create (verified r6).\n\n## Factory emission (opt-in)\n\n**Status: cookbook-level (C) — rules below are derived from module\nsource in this repository, not yet exercised against live imports.**\nEverything in this section meets that standard and no higher; treat\nunmarked claims as unverified.\n\nThe manifest owns the choice, per resource family. `emission` is a\nmap of family → style, where family names are the canonical-map rows\nand styles are family-specific: `per-instance` (default) or `factory`\nfor module-instance families (those without a factory carrier reject\n`factory`), and `additive` (default) or `authoritative` for the `iam`\npseudo-family (see \"Organization and containers: IAM\"). It lives on\nthe scope entry, like everything else in the manifest: the grammar is\nscopes-only, and there is no top-level position.\n\nResolution, per family:\n\n```text\nscopes[].emission.<family>  →  built-in default (per-instance / additive)\n```\n\nOmission means the built-in default — for `emission` only. **Do not\ngeneralise this to `types:`.** Both keys sit on a scope entry, and\nthere the resemblance stops: `emission` is denominator-neutral (it\ndecides how collected assets are written, and a missing family key\nfalls back safely), while `types:` decides what is collected at all —\nevery scope must declare its full list, and an omission is a refused\nmanifest, not a default. A wrong emission choice still faces both\ngates; a wrong type list shrinks the denominator the gates are\nmeasured against.\n\n```yaml\nscopes:\n  - name: org-foundation\n    levels: [organization, folder]\n    # types: … (each scope carries its own list — elided here)\n    emission:\n      folder: factory        # -> project-factory hierarchy data/\n\n  - name: sandbox\n    levels: [folder, project]\n    include: [folders/333333333333]\n    # types: …\n    # no emission: -> built-in defaults (per-instance; iam additive)\n```\n\n### Subtree-granular emission (scope splitting)\n\nEmission granularity follows scope granularity: to manage *some*\nfolders via factory and others per-instance, split the tree into\nscopes along subtree boundaries and give each its own `emission:` —\nscope `include`/`exclude` already speak subtrees (CAI `ancestors`\nmatching), so no new selector machinery is needed. Remember the\ncoupling: every scope carries its own `types:` list, so a scope added\nfor emission reasons declares its list like any other (usually a copy\nof the list it split from):\n\n```yaml\nscopes:\n  - name: teams               # this subtree is factory-managed\n    root: organizations/000000000000\n    include: [folders/111111111111]\n    levels: [folder]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n    emission:\n      folder: factory\n\n  - name: rest                # everything else stays per-instance\n    root: organizations/000000000000\n    exclude: [folders/111111111111]\n    levels: [folder]\n    types: *folder-types      # same list; YAML anchors avoid drift\n```\n\nRules that make the split sound (from `project-factory` source,\n`folders.tf`):\n\n- **Factory subtrees can be rooted mid-tree.** A level-1 factory\n  folder takes an explicit `parent` in its `.config.yaml`\n  (`parent = coalesce(<config parent>, \"$folder_ids:default\")`),\n  resolvable through `context.folder_ids` — so a factory-managed\n  subtree may sit under a per-instance-managed or unmanaged folder.\n- **One factory instance for the whole workspace.** All\n  factory-emitted folders share a single `project-factory` instance\n  and one `data/` hierarchy, regardless of how many scopes opted in:\n  disjoint subtrees become multiple top-level `data/` directories,\n  each with an explicit `parent`. The factory is workspace-shaped,\n  not scope-shaped — scopes decide what is collected, emission how it\n  is written.\n- **Boundary crossings never use literals.** Downward\n  (per-instance child under a factory folder):\n  `parent = module.<pf>.folder_ids[\"<path>\"]`. Upward (factory\n  subtree under a per-instance folder): `parent:` in the subtree\n  root's `.config.yaml`, fed via the factory's `context.folder_ids`\n  input. Every style boundary is a seam: name it in the run report.\n- **The 4-level cap applies per factory subtree**, since each subtree\n  root is level 1 — splitting deep hierarchies along scope boundaries\n  also relieves the nesting-depth tradeoff.\n\nWhen to opt in: the imported workspace is the intended day-2 operating\nmodel and the family's ongoing management is data-shaped — the\ncanonical case is foundational infrastructure, with the folder tree,\nfolder IAM and org policies maintained as hierarchical YAML. When in\ndoubt, stay per-instance; graduating a family to factory emission later\nis a `moved {}` exercise, per instance, at a time of your choosing.\n\n### Tradeoffs (why per-instance stays the default)\n\n- **Key = data path, blast radius = subtree.** In `project-factory` the\n  hierarchy directory path IS the instance key: renaming or moving a\n  directory re-keys every folder and project below it — a\n  destroy/create plan across the subtree. The same immutability rule as\n  instance keys applies (choose the `data/` layout once, record it in\n  `coverage-map.yaml`), but the coupling is structural, not per-key.\n  D-04 recovery applies unchanged if it ever happens.\n- **Deeper, internal addresses.** Import blocks target factory\n  internals — these are not a stable interface across Fabric refs, so\n  \"read the module source in this repository, do not trust memory\" does\n  double duty here. Re-verify address shapes on every Fabric ref bump.\n- **Nesting depth.** `project-factory` supports at most 4 folder\n  levels (hardcoded `module.folder-1` … `module.folder-4`); the\n  per-instance `modules/folder` chain has no limit. A hierarchy deeper\n  than 4 cannot be factory-emitted.\n- **The reference rule changes carrier.** Inside factory YAML,\n  references are context interpolations (`$folder_ids:<path>`,\n  `$iam_principals:…`), not module outputs. The rule itself is\n  unchanged — no literals for managed resources — only its spelling:\n  - **Level-1 folders** (under the organization or a boundary parent):\n    `parent: \"organizations/<org_id>\"` or `parent: \"$folder_ids:default\"`.\n  - **Nested folders (Level 2+)**: `parent: \"$folder_ids:<parent_path>\"`\n    (e.g. `parent: \"$folder_ids:networking\"` in `networking/production/.config.yaml`).\n    While `project-factory` defaults omitted `parent` on child folders to\n    `\"$folder_ids:${parent_key}\"`, emitting it explicitly makes hierarchy\n    dependencies clear and self-documenting in the YAML data.\n\n### project-factory: addresses and layout (from module source)\n\n- Folders: `module.<pf>.module.folder-<level>[\"<path>\"]` where\n  `<level>` is 1-based directory depth and `<path>` the `data/`-relative\n  directory path. Folder IAM lives on a **separate** paired instance,\n  `module.<pf>.module.folder-<level>-iam[\"<path>\"]` — both must appear\n  in `coverage-map.yaml` for a folder with IAM.\n- Projects: `module.<pf>.module.projects[\"<key>\"]`; service accounts:\n  see `projects-service-accounts.tf` for the key format.\n- Cross-emission references: the factory outputs `folder_ids` /\n  `project_ids` / `iam_principals` keyed by hierarchy path, so mixed\n  mode composes without literals — a per-instance project under a\n  factory folder takes `parent = module.<pf>.folder_ids[\"<path>\"]`.\n  The inverse (factory projects under per-instance folders) goes\n  through the factory's `context.folder_ids` input.\n- Escaping: the factory-YAML rows of the escaping table below apply\n  wholesale; check `templatestring` usage per field with the same\n  `grep -rn templatestring modules/project-factory/` discipline.\n\nOther carriers (`net-vpc` subnet factory, `net-firewall-policy` rule\nfiles, …): same method — read the carrier's `factories_config` source,\nderive address + key formats, and add a subsection here as part of the\nrun report, marking what was exercised. `FACTORIES.md` at the repo root\nis the authoritative catalog of factory carriers.\n\n## Universal rules\n\n### Imports and coverage\n\n- Every emitted resource gets a paired `import` block in the owning\n  instance's `*-import.tf`, and an entry in `tf/coverage-map.yaml`.\n- Deterministic keys: map keys become Terraform addresses; derive them\n  from content, never from list position. For additive IAM use\n  `slug(role)_slug(member)`, with\n  `_sha256(role\\0member\\0title\\0expression)[:8]` appended when\n  conditional; for authoritative conditional bindings use\n  `slug(role)_slug(title)_sha256(role\\0title\\0expression)[:8]`.\n- Run `terraform fmt -recursive` as the last emission step;\n  `terraform fmt -check -recursive` must pass before the gates. Fmt-ed\n  output is the canonical form the determinism and checksum assertions\n  compare (verified plan-invariant, r11).\n\n### Escaping\n\nWhat to escape depends on two things: the carrier (HCL input or factory\nYAML) and whether the module passes the field through\n`templatestring()`. Check with\n`grep -rn templatestring modules/<module>/` — do not guess.\n\n| Field | Carrier | Templated? | Escape `${` as |\n|---|---|---|---|\n| IAM condition expressions (org, folder, project modules) | HCL input | yes | `$$${` |\n| Org-policy values / condition fields / parameters (all levels) | factory YAML | yes | `$${` |\n| Members, titles, filters, descriptions (non-templated inputs) | HCL input | no | `$${` |\n\n`%{` follows the same pattern with `%`. Escape the `${` and `%{`\nsequences only — never blanket-replace `$`: in HCL, `$$` not followed\nby `{` is two literal dollars, so broad replacement corrupts values\n(caught in r7 scaffolding review). Literal `${` round-trip is verified\nend to end (r3, seeded condition title).\n\n### The reference rule\n\nIf a referenced resource is managed in the workspace, refer to it\nthrough its module output — never by literal string:\n`project_id = module.project-<key>.project_id`,\n`parent = module.folder-<key>.id`, `module.sa-<key>.iam_email` for\nmembers where the SA is managed. Literals are only for references that\nleave the managed graph (a billing account, an unimported destination),\nand every such literal is an \"unmanaged reference\" line in the run\nreport. When a referenced resource later graduates into management,\nupgrading the literal to a module output is part of that import's\ncommit (verified r10: sink destinations). Two deliberate exceptions\nstay literal: import block `id`s and `coverage-map.yaml` — they are\njoin artifacts, and determinism and auditability win.\n\n### Scope semantics\n\n- References never widen scope. Managing config that references an\n  unmanaged resource is fine — org sinks converge without their\n  destinations, IAM bindings without their SA principals — as long as\n  convergence does not depend on the referenced side and the report\n  names it. The manifest decides scope.\n- Scopes narrow on two axes: subtrees (`include`/`exclude`) and what\n  is collected inside them (`levels`, and each scope's own `types:`\n  list). Both are Scope-Approval-gate surface: they move the\n  denominator.\n- Manifest `include` / `exclude` match the CAI `ancestors` array,\n  which stores numeric ids. Both spellings work: `projects/123456789`\n  (the project NUMBER) matches directly, and a project ID is resolved\n  to its number by `inventory.py` via `gcloud projects describe` —\n  prefer the number where known, since resolution needs the describe\n  permission. See `examples/import-manifest.org-foundation.yaml` for\n  the include-vs-exclude trap and\n  `examples/import-manifest.multi-domain.yaml` for selecting specific\n  projects per scope.\n\n#### Per-scope type declaration (`scopes[].types`)\n\nEvery scope carries its own `types:` list — there is no other place\nto declare types, so what a scope collects is exactly what is written\non it. The same type may appear in several scopes with different\n`levels`, `iam` or `enumerate`; an `enumerate:` block is swept only\nin the scopes whose list names it.\n\nFail-closed rules (all refuse the manifest before any API call):\n\n- a scope without a `types:` list, or with `types: []`, is refused —\n  an empty list is a scope that collects nothing, and that is the one\n  thing no scope may say quietly;\n- a type whose `levels` cannot intersect its scope's `levels` is\n  refused as a dead declaration (an entry listing `unknown` is\n  exempt): it reads as coverage and produces none;\n- a duplicate `type:` inside one scope's list is refused.\n\nProvenance in `inventory.json`: each `_meta.scopes[]` entry records\nits own `declared_types` yield counts and `zero_yield_types`;\n`_meta.declared_types` is the aggregate; every inventory entry names\nthe scope(s) that collected it (`scopes: [...]`, merged and sorted\nwhen scopes overlap). **Read the per-scope counts, not only the\naggregate** — a type that yields zero in one scope and non-zero in\nanother never appears in the aggregate zero-yield warning. Same\ndiscipline as `_meta.split_parity`: read the record, not the key.\n\n### Serialization traps\n\n- YAML emission: quote YAML 1.1 booleans (`yes/no/on/off`), preserve\n  multi-line strings verbatim (block style, no rstrip), never sort\n  ordered lists.\n- The API omits false booleans in JSON output (`includeChildren` absent\n  means `false`, not `true`).\n- Empty string vs null — **check which of the two shapes you are facing\n  before reaching for a benign rule** (r17):\n\n  1. **Module passes the variable through directly**\n     (`description = var.description`, with a non-empty variable\n     `default`). Setting the input explicitly to `null` round-trips a\n     live empty or unset value cleanly, because Terraform treats an\n     explicit null as \"attribute not present\" and plans no diff.\n     Verified on `modules/bigquery-dataset` (r17): a live dataset with\n     `description = \"\"` planned `\"\" -> \"Terraform managed.\"` until\n     `description = null` was set, after which it imported clean. **This\n     is the preferred fix — a config change, not a rule.**\n  2. **Module wraps the field in `coalesce(var.x, default)`.** Terraform's\n     `coalesce` skips null AND empty strings, so the default always\n     wins and no config value can express \"empty\". `null` does nothing\n     here. This is the only shape where a `when_before`-guarded benign\n     rule is warranted — D-03, verified on org log sinks\n     (`modules/organization/logging.tf` uses\n     `coalesce(each.value.description, \"...\")`).\n\n  Tell them apart with\n  `grep -n '<field>' modules/<module>/*.tf`. Reaching for a rule when\n  shape 1 applies means shipping a benign entry that a one-line config\n  change would have removed. Never an unguarded attribute rule in\n  either case.\n- ForceNew inputs: for every module, check which inputs are ForceNew on\n  the underlying resource and mirror live values exactly BEFORE the\n  first plan. A destroy/create pair on an imported resource is always a\n  mapping error. Known cases: network/subnet `description` (r9), log\n  bucket `parent` format (r10).\n- **Silently dropped module inputs.** Terraform's object type conversion\n  DISCARDS attributes a module's variable type does not declare — with\n  no error and no warning. `terraform validate` returns Success. So a\n  field you believe you have set may never reach the resource, and from\n  the plan alone that looks identical to a provider artifact. Verified\n  against `modules/certificate-authority-service` before #4107: passing\n  `publishing_options` produced a clean validate and the destructive\n  removal diff regardless.\n\n  Diagnosis rule: when a diff persists on an attribute you are certain\n  you have set, read the module's variable TYPE before concluding\n  anything (`grep -A20 'variable \"<name>\"' modules/<module>/variables.tf`).\n  A silently-dropped input is the mechanical tell for a module\n  capability gap, and it is the single easiest way to mistake a gap for\n  benign drift.\n\n## Workspace layout (normative)\n\nOne file per container instance (organization, folder, project) and a\ndata tree that mirrors the hierarchy using instance keys as path\nsegments — never display names, so renames touch nothing and\nreparenting is a cosmetic `git mv`:\n\n```\ntf/\n├── versions.tf  organization.tf  organization-import.tf\n├── folder-<key>.tf / project-<key>.tf     # one per CONTAINER, + -import.tf\n├── coverage-map.yaml\n└── data/\n    ├── organization/{org-policies,custom-roles}/*.yaml\n    └── folders/<key-a>/org-policies/…            # top-level folder\n        └── <key-b>/org-policies/…                # child nested under parent\n            └── projects/<proj-key>/org-policies/…\n```\n\nProject-contained resources live in their project's file. Every module\ninstance whose resource lives inside a project — service accounts,\nbuckets, VPCs, datasets — goes in that project's `project-<key>.tf`\n(import blocks in `project-<key>-import.tf`), not in a file of its own.\nA project file is the complete, reviewable inventory of that project.\nOnly containers get their own files (owner decision after r8).\n\nFile placement is address-neutral (verified r11: plan-invariant).\nRe-homing a module block between files changes no address, no state, no\ncoverage-map entry — so re-layouts are always safe and do not violate\nincremental immutability, which governs addresses and instance keys,\nnever file placement. Do re-layouts as dedicated, mapping-free commits.\n\nEach instance's `factories_config` points at exactly its own subtree.\nModule-input config (IAM, sinks) lives in the instance's `.tf` file;\nonly factory-driven YAML lives under `data/`. The same constraint at\ndifferent levels lands on different paths — no collisions.\n\n## Scaffolding scripts (run-local generators)\n\nWriting a helper script to emit the workspace is legitimate — with\ngovernance:\n\n1. Workspace-confined and org-confidential: scripts embed org data;\n   they live in the workspace (or its private repo), never in the\n   skill. Parameterize by construction — org id and container ids come\n   from the manifest, `inventory.json` or argv, never hardcoded.\n2. The cookbook is the spec; the script is an implementation. A\n   divergence is a defect even while gates are green, because gates\n   only verify paths the live org exercises today (r7 review found four\n   such latent divergences).\n3. Scope comes from the frozen denominator: the set of emitted things\n   joins against `inventory.py` keys. Detail fetches (`describe`,\n   `get-iam-policy`) are fine.\n4. Deterministic: sorted iteration, stable keys, double-run\n   byte-identical, `terraform fmt` as the last step.\n5. No silent promotion: durable knowledge becomes cookbook prose;\n   durable mechanism goes through human review into a tested frozen\n   tool. Never \"the script works, keep it\".\n6. Reported: the run report lists scaffolding used and gate verdicts;\n   commit the script as round evidence (sanitized and parameterized).\n7. Strict address matching: When filtering or mutating import blocks\n   across incremental runs, match full resource type strings (e.g.\n   `google_organization_iam_custom_role`) rather than loose substrings\n   (like `\"custom_role\"`), which collide with role IDs such as\n   `roles/latchkey_custom_role_automation`.\n\n## Import-ID quick table\n\n**Generated — do not edit by hand.** The rows below are rendered from\n[`address-map.yaml`](./address-map.yaml), which owns the fielded subset\nof this cookbook: asset type, module, address pattern, import ID and the\nround that verified it. Edit the YAML and re-render:\n\n```bash\nuv run scripts/address_map.py --render-cookbook\n```\n\nTwo invariants are machine-enforced there and cannot be restated here by\naccident: an address pattern is claimed by exactly one entry, and a CAI\nasset type serviced by more than one module must say how to tell the\ncarriers apart. `<placeholder>` segments are chosen per engagement.\nA blank `Verified` cell means the surface was never exercised live —\nan honest gap, not an implied guarantee.\n\n<!-- BEGIN ADDRESS-MAP (generated) -->\n\n| Resource | Module | Address pattern | Import ID | Verified |\n|---|---|---|---|---|\n| BigQuery Dataset | `modules/bigquery-dataset` | `module.<instance>.google_bigquery_dataset.default` | `projects/<project_id>/datasets/<dataset_id>` | r17 |\n| CAS CA Pool | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool.default[0]` | `projects/<project_id>/locations/<location>/caPools/<name>` | r14 |\n| CAS CA Pool IAM | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role>` | — |\n| CAS CA Pool IAM (cond.) | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>` | — |\n| Certificate Authority | `modules/certificate-authority-service` | `module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]` | `projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>` | — |\n| DNS zone | `modules/dns` | `module.<instance>.google_dns_managed_zone.dns_managed_zone[0]` | `projects/<project_id>/managedZones/<zone>` | r12 |\n| DNS recordset | `modules/dns` | `module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]` | `projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>` | — |\n| DNS Response Policy | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy.default[0]` | `projects/<project_id>/responsePolicies/<response_policy_name>` | — |\n| DNS Response Policy rule | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]` | `projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>` | — |\n| Folder | `modules/folder` | `module.folder-<key>.google_folder.folder[0]` | `folders/<id>` | r6 |\n| Folder IAM | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]` | `folders/<id> <role>` | r6 |\n| Folder IAM (cond.) | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]` | `folders/<id> <role> <condition_title>` | r6 |\n| Folder IAM member (additive, default) | `modules/folder` | `module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]` | `folders/<id> <role> <member>` | — |\n| Folder sink | `modules/folder` | `module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]` | `folders/<id>/sinks/<name>` | r6 |\n| Folder org policy | `modules/folder` | `module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `folders/<id>/policies/<constraint>` | r6 |\n| GCS bucket | `modules/gcs` | `module.<instance>.google_storage_bucket.bucket[0]` | `<project_id>/<bucket_name>` | r12 |\n| Service account | `modules/iam-service-account` | `module.sa-<key>.google_service_account.service_account[0]` | `projects/<project_id>/serviceAccounts/<email>` | r8 |\n| SA IAM binding | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/serviceAccounts/<email> <role>` | r8 |\n| SA IAM binding (cond.) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <condition_title>` | r8 |\n| SA IAM member (additive, default) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <member>` | — |\n| KMS CryptoKey | `modules/kms` | `module.<instance>.google_kms_crypto_key.default[\"<key>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>` | r13 |\n| KMS CryptoKey IAM | `modules/kms` | `module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>` | r13 |\n| KMS KeyRing | `modules/kms` | `module.<instance>.google_kms_key_ring.default[0]` | `projects/<project_id>/locations/<location>/keyRings/<name>` | r13 |\n| Log bucket | `modules/logging-bucket` | `module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]` | `projects/<project_id>/locations/<location>/buckets/<name>` | r10 |\n| NCC Hub | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_hub.hub[0]` | `projects/<project_id>/locations/global/hubs/<hub_name>` | — |\n| NCC spoke (router appliance) | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_spoke.spoke_ra` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | — |\n| Compute address (global PSA) | `modules/net-address` | `module.<instance>.google_compute_global_address.psa[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (global PSC) | `modules/net-address` | `module.<instance>.google_compute_global_address.psc[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (regional internal) | `modules/net-address` | `module.<instance>.google_compute_address.internal[\"<key>\"]` | `projects/<project_id>/regions/<region>/addresses/<name>` | r19 |\n| Cloud NAT | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router_nat.nat` | `projects/<project_id>/regions/<region>/routers/<router>/<nat>` | r12 |\n| Cloud Router | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | r12 |\n| Hierarchical Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_firewall_policy.hierarchical[0]` | `locations/global/firewallPolicies/<id>` | r7 |\n| Global Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_network_firewall_policy.net_global[0]` | `projects/<project_id>/global/firewallPolicies/<name>` | r7 |\n| Regional Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]` | `projects/<project_id>/regions/<region>/firewallPolicies/<name>` | r7 |\n| Interconnect Attachment | `modules/net-vlan-attachment` | `module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]` | `projects/<project_id>/regions/<region>/interconnectAttachments/<name>` | — |\n| VPC network | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_network.network[0]` | `projects/<project_id>/global/networks/<name>` | r3 |\n| VPC route (gateway) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ilb) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (instance) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ip) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (vpn tunnel) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC subnet | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r3 |\n| VPC subnet (private NAT) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | — |\n| VPC subnet (proxy-only) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r9 |\n| VPC firewall rule (custom) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]` | `projects/<project_id>/global/firewalls/<rule_name>` | — |\n| VPC firewall rule (default, admins) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_admins[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-admins` | — |\n| VPC firewall rule (default, http) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_http[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http` | — |\n| VPC firewall rule (default, https) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_https[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https` | — |\n| VPC firewall rule (default, ssh) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_ssh[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh` | — |\n| External VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]` | `projects/<project_id>/global/externalVpnGateways/<name>` | — |\n| HA VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]` | `projects/<project_id>/regions/<region>/vpnGateways/<name>` | — |\n| Cloud Router (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | — |\n| Cloud Router interface (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]` | `<project_id>/<region>/<router_name>/<interface_name>` | — |\n| Cloud Router BGP peer (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>` | — |\n| VPN Tunnel | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/vpnTunnels/<name>` | — |\n| Org audit config | `modules/organization` | `module.organization.google_organization_iam_audit_config.default[\"<service>\"]` | `<org> <service>` | r3 |\n| Custom role | `modules/organization` | `module.organization.google_organization_iam_custom_role.roles[\"<id>\"]` | `organizations/<org>/roles/<id>` | r3 |\n| Org IAM binding (authoritative opt-in) | `modules/organization` | `module.organization.google_organization_iam_binding.authoritative[\"<role>\"]` | `<org> <role>` | r3 |\n| Org IAM binding (cond.) | `modules/organization` | `module.organization.google_organization_iam_binding.bindings[\"<key>\"]` | `<org> <role> <condition_title>` | r3 |\n| Org IAM member (additive, default) | `modules/organization` | `module.organization.google_organization_iam_member.bindings[\"<key>\"]` | `<org> <role> <member>` | — |\n| Org log sink | `modules/organization` | `module.organization.google_logging_organization_sink.sink[\"<name>\"]` | `organizations/<org>/sinks/<name>` | r3 |\n| Org policy | `modules/organization` | `module.organization.google_org_policy_policy.default[\"<constraint>\"]` | `organizations/<org>/policies/<constraint>` | r5 |\n| Tag Key | `modules/organization` | `module.organization.google_tags_tag_key.default[\"<short_name>\"]` | `tagKeys/<id>` | r18 |\n| Tag Value | `modules/organization` | `module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]` | `tagValues/<id>` | r18 |\n| Tag Binding | `modules/organization` | `module.<instance>.google_tags_tag_binding.binding[\"<key>\"]` | `tagBindings/<url-encoded-parent-and-value>` | r18 |\n| PAM entitlement | `modules/project` | `module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]` | `<parent>/locations/<location>/entitlements/<entitlement_id>` | — |\n| Project | `modules/project` | `module.project-<key>.google_project.project[0]` | `projects/<project_id>` | r4 |\n| Project IAM | `modules/project` | `module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]` | `<project_id> <role>` | r4 |\n| Project IAM (cond.) | `modules/project` | `module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]` | `<project_id> <role> <condition_title>` | r4 |\n| Project IAM member (additive, default) | `modules/project` | `module.project-<key>.google_project_iam_member.bindings[\"<key>\"]` | `<project_id> <role> <member>` | — |\n| Project org policy | `modules/project` | `module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `projects/<project_id>/policies/<constraint>` | — |\n| Project service | `modules/project` | `module.project-<key>.google_project_service.project_services[\"<api>\"]` | `<project_id>/<api>` | r4 |\n| Project service (orgpolicy) | `modules/project` | `module.project-<key>.google_project_service.org_policy_service[0]` | `<project_id>/orgpolicy.googleapis.com` | r4 |\n| Workload Identity Pool | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>` | r18 |\n| Workload Identity Pool Provider | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>` | r18 |\n| Pub/Sub Topic | `modules/pubsub` | `module.<instance>.google_pubsub_topic.default` | `projects/<project_id>/topics/<topic_name>` | r17 |\n| VPC-SC Access Level | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]` | `accessPolicies/<policy_id>/accessLevels/<level_name>` | r15 |\n| VPC-SC Access Policy | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_policy.default[0]` | `<policy_id>` | r15 |\n| VPC-SC Service Perimeter | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]` | `accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>` | r15 |\n| NCC spoke (raw — capability gap) | raw | `google_network_connectivity_spoke.<instance>` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | r12 |\n\n<!-- END ADDRESS-MAP -->\n\nVerified across r2–r19 (see COVERAGE.md for the per-family evidence).\nFor anything else: check the provider documentation's import section, or\nemit the import block with the CAI asset name minus the\n`//<service>.googleapis.com/` prefix — verified to match the provider\nimport ID byte-for-byte on every family tested (r5 oracle, 100%) — and\nlet plan tell you: it errors loudly and safely on a wrong ID.\n\n## Per-type rules\n\n### Organization: org policies\n\n- Factory files in `data/.../org-policies/` wrap each policy in a map\n  keyed by the constraint name (`<constraint>: {rules: [...]}`) — the\n  module merges raw YAML via `yamldecode()` (verified r5).\n- `dryRunSpec` is real config: emit it under a `dry_run:` block in the\n  same constraint definition. A dry-run-only policy must never become\n  `rules: []` (verified r4; CAI omits dry-run-only policies — the\n  inventory service-API sweep exists for exactly this).\n- `parameters` must be a JSON string (the module feeds it to\n  `templatestring()`), serialized deterministically\n  (`json.dumps(..., sort_keys=True)`) (verified r3).\n- Factory `fileset()` is non-recursive: filenames are\n  `<constraint>.yaml`, never subdirectories.\n- Value lists are ordered attributes: preserve live order (verified r3).\n- **Verbatim condition expressions**: Condition expressions in org policy\n  rules returned by the API can carry a trailing newline (`\\n`). In factory\n  YAML, use block scalar style (`|`) rather than quoted/stripped strings so\n  the trailing newline is preserved and does not plan as an in-place update.\n\n### Organization: custom roles\n\n- Factory configuration:\n  `factories_config = { custom_roles = \"<path>\" }`.\n- Factory YAML schema: `includedPermissions: [...]` (matching the IAM\n  API attribute), `title`, `description`, `stage` (verified r5).\n- Title/description/stage need a Fabric ref containing PR #4102\n  (released in `v58.0.0`); on `v57.0.0` and earlier they plan as\n  in-place updates (D-01).\n\n### Organization and containers: IAM\n\n- **Additive is the default emission for IAM** (manifest\n  `emission.iam`, styles `additive` | `authoritative`, same two\n  positions and resolution as every other emission family). Each live\n  (role, member, condition) tuple maps to one `iam_bindings_additive`\n  entry → one `google_*_iam_member.bindings[\"<key>\"]`. Why default:\n  an apply can only create or destroy the exact tuples emitted — it\n  can never remove members it does not manage — and it is the\n  non-authoritative posture Google's PAM documentation recommends.\n  The honest tradeoff: additive emission cannot converge out-of-band\n  *additions* away — unmanaged members accumulate invisibly to the\n  plan, while coverage still counts the container policy as mapped.\n  Estates that want IAM fully declarative as the day-2 model opt into\n  `emission.iam: authoritative` (`iam` / `iam_bindings` maps — the\n  emission every existing verified round exercised); the choice is\n  the human's, recorded in the manifest.\n- Assert IAM policy `version: 3` when conditions exist — v1/v2\n  responses flatten conditions, which is silent privilege escalation.\n- `deleted:` principals cannot be imported; exclude them from bindings\n  but always surface them in the report (under additive emission the\n  tombstones simply stay unmanaged; under authoritative emission an\n  apply would remove them).\n- **PAM grant bindings are runtime state, never configuration —\n  excluded from the denominator by `inventory.py`, not by you.** On\n  grant activation, Privileged Access Manager injects a temporary\n  time-bound conditional binding into the container's allow policy and\n  revokes it itself when the grant ends; Google's PAM documentation\n  explicitly warns against managing these bindings in Terraform.\n  Whenever IAM is collected, `inventory.py` also sweeps active grants\n  through CAI (`privilegedaccessmanager.googleapis.com/Grant` — CAI\n  models the Grant, not the Entitlement; one extra call per scope,\n  covered by the same `cloudasset.viewer` grant) and strips matching\n  bindings before normalization. Matching is deterministic — (target,\n  role, requester) come from the grant resource itself, never from\n  string-matching the binding's condition, whose format Google does\n  not publish — and deliberately narrow: conditional bindings only, so\n  a permanent binding coinciding with a grant is kept. A container\n  whose policy holds only grant bindings mints no `#iam` entry:\n  structural exemption, not a waiver. Stripped bindings are stamped in\n  `_meta.pam_grant_exclusions`; quote them in the run report's\n  machine-managed exclusions. At emission time, work from the stamped\n  list — if a live policy shows a conditional binding that looks\n  PAM-shaped but is not in the stamp, the inventory is stale:\n  re-collect, do not classify by hand. Exclusion is also\n  collision-safe: under the additive default nothing unmanaged can be\n  removed by construction, and even under authoritative opt-in Fabric\n  `iam` maps produce bindings authoritative for the (role,\n  no-condition) tuple while PAM bindings always carry a condition, so\n  an apply of the emitted config cannot revoke an active grant. Declaring the Grant type in a manifest is refused. Do not\n  confuse grants with PAM *entitlements*, which are ordinary importable\n  configuration mapping to the `pam_entitlements` factory on\n  `organization` / `folder` / `project`.\n- Audit configs map to `logging_data_access` on `modules/organization`\n  (verified r3).\n- Conditional binding import IDs need a non-empty condition title;\n  format is space-separated:\n  `<org_or_folder_or_project_id> <role> <condition_title>`.\n- **Multi-module state consolidation**: When inferring from Terraform\n  state where IAM for the same scope/role is split across multiple module\n  instances (e.g. `module.organization`, `module.organization-iam`, or\n  dynamic factory loops): under authoritative emission, member lists\n  must be union-merged across all matching state bindings before\n  mapping into canonical module `iam` maps; under additive emission,\n  deduplicate (role, member, condition) tuples across instances\n  instead — each tuple is emitted exactly once.\n- **Verbatim condition expressions & ForceNew**: IAM condition expressions\n  from live API policies frequently contain a trailing newline (`\\n`).\n  Because `condition.expression` on `google_*_iam_binding` is `ForceNew`,\n  stripping whitespace or omitting the newline triggers a spurious\n  destroy/create (replacement) plan. Preserve the exact string from the live\n  API (e.g. via HCL heredoc `<<-EOT` or explicit `\\n`).\n\n### Folders (`modules/folder`, one instance per folder)\n\n- Chain parents by module reference\n  (`parent = module.folder-<parent-key>.id`; top-level folders point at\n  the org). No nesting-depth limit — unlike factory emission, which\n  caps at 4 levels (see \"Factory emission (opt-in)\").\n- Set `deletion_protection = true` (imported provider default; avoids a\n  spurious in-place change — verified r2).\n- Folder-level org policies, sinks and IAM live on the same instance as\n  the folder — never a separate wrapper.\n- Display-name renames are plain `name` updates (verified r6).\n- Re-keying recovery (D-04) — only if instance keys themselves must\n  change, which should be never. If a plan ever shows a destroy/create\n  pair on the same folder id:\n  1. Stop — never let that plan stand, even unapplied.\n  2. Rename the factory path/key to the new slug (content unchanged).\n  3. Add `moved { from = <old address> to = <new address> }` at the\n     workspace root and keep the block permanently.\n  4. Update the `coverage-map.yaml` entry's address in the same commit\n     (the one sanctioned exception to address immutability — the key\n     stays, the address moves with the `moved` block as evidence).\n  5. Re-plan: the pair must collapse to no-op or update before anything\n     else proceeds. Automatic `moved {}` generation is deliberately\n     deferred until a real engagement needs it at scale.\n\n### Projects (`modules/project`, one instance per project)\n\n- Chain parent by module reference or the org id for top-level\n  projects.\n- Set `deletion_policy = \"PREVENT\"`. `auto_create_network = true`\n  matched live state with zero diff on the pilot projects — confirm per\n  project via plan rather than assuming (r7).\n- `services`: the enabled-service list from the live ServiceUsage API.\n  `orgpolicy.googleapis.com` maps internally to\n  `google_project_service.org_policy_service[0]`; every other service\n  maps to `google_project_service.project_services[\"<api>\"]` (r7).\n- CAI can report ghost services (disabled in ServiceUsage but present\n  in CAI): cross-check `gcloud services list` and waive with evidence\n  (r7).\n- `service_agents_config`: set `{ create_primary_agents = false,\n  grant_default_roles = false, grant_service_agent_editor = false }` on\n  imported projects to avoid unwanted resource creation or default role\n  injection (r7).\n- `labels`: pass `{}` or live labels. Provider-computed\n  `terraform_labels` diffs are covered by a `when_before`-guarded\n  benign rule (r7).\n\n### Service accounts (`modules/iam-service-account`) — verified r8\n\n- Manifest: `iam.googleapis.com/ServiceAccount` with `iam: true` —\n  without the opt-in, impersonation grants (IAM on the SA) are\n  invisible to the denominator.\n- Google-managed and service-agent SAs (`*-compute@developer.`,\n  `*@cloudservices.`, `service-*@gcp-sa-*`) must be waived, never\n  imported — they are lifecycle-bound to services, not user config.\n- SA keys: do not import or enumerate user-managed keys (secrets);\n  waive and note in the report.\n- Reference rule: `project_id = module.project-<key>.project_id`;\n  impersonation members that are themselves managed SAs use\n  `module.sa-<key>.iam_email`; unmanaged principals stay literal and\n  are listed in the report.\n\n### Log sinks (org/folder level)\n\n- Never import `_Default` / `_Required` (Google-managed; automatically excluded from the denominator by `inventory.py`).\n- The module `type` enum is `gcs` (not `storage`), `bigquery`,\n  `logging`, `pubsub`, `project` — map the destination API prefix\n  accordingly; fail on an unknown prefix, never rewrite (r7).\n- `destin\n\n<!-- [TRUNCATED AT 50KB] -->","skills/fabric-importer/references/operating-contract.md":"# fabric-importer — operating contract\n\nRules for any agent or human operating this skill.\n\n## 1. Trust boundary\n\nThe skill is intentionally lean: the model performs discovery-to-Terraform\nmapping itself, and correctness comes from un-gameable verification, not\nfrom deterministic codegen. That only holds if the boundary is respected:\n\n| Layer | Contents | Who may change it |\n|---|---|---|\n| Frozen scripts | everything in `scripts/`, rulesets included (`benign-drift.yaml`) | Humans, via code review only. The model may run them, never edit them. |\n| Human-owned run files | `import-manifest.yaml`, `waivers.yaml` | Humans. The model drafts/proposes; a human commits. |\n| Model workspace | `tf/`, `coverage-map.yaml`, reports, worklists | The model — with immutable-address discipline (§4). |\n\nA converged verdict is only meaningful if the gates and their rulesets\nwere not touched by the same actor whose output they verify.\n\nBoth gates stamp runtime provenance into their output, covering every\nverification script. To check a recorded verdict, run `uv run\nscripts/integrity.py` from a clean checkout of the same commit and\ncompare; `--verbose` identifies which file differs. This is evidence,\nnot prevention — an actor editing a gate could edit the stamp — but it\nremoves the silent case, and any stored gate output can be checked after\nthe fact.\n\nThe script provenance alone does not prove which *inputs* a gate judged,\nso both gates also stamp every input they read — resolved path and\nSHA256 — on the line after the digest: the plan JSON (or stdin), the\nrules file actually loaded, the inventory, the coverage map, the waiver\nledger, and the set of `*.tf` files scanned. A non-default `--rules`\nfile additionally prints a loud warning and downgrades a converged\nverdict to exit code 3, so a substituted ruleset can never produce a\npassing gate — otherwise\npointing the gate at a permissive file would be a cheaper route to green\nthan editing the frozen file this contract forbids editing. A recorded verdict is therefore attributable to\nexact inputs, not just to a tool build.\n\nThe gates also fail closed on degenerate inputs: a plan without\n`format_version` and `resource_changes` (state JSON, `{}`), a plan with\nzero resource changes without `--allow-empty-plan`, and an empty\ninventory without `--allow-empty-inventory` all exit non-zero. An empty\nverdict is never evidence of convergence.\n\n**Which decisions need a human.** The pattern behind this table is that\nevery human-owned artifact either narrows what the gates check or is\nirreversible. Scope narrows the denominator — on two axes, since each\nscope carries its own `types:` list, and the tool refuses the shapes\nwhere that narrowing would be invisible (a scope without a list, an\nempty list, a declaration whose levels cannot match its scope), because\neach of those would shrink the denominator while still exiting 0. A\nwaiver removes an entry from the denominator; a benign rule removes an\nentry from the residual set; an apply\ncannot be undone. Everything else — module choice, file layout, instance\nkeys, raw-vs-module calls — is falsifiable by the plan and can be\nmodel-owned. Use that test when something new appears: *does this shrink\nwhat is checked, or is it irreversible?* If yes, a human decides.\n\nThe manifest is human-owned for that reason, and it is why a manifest is\nthe only place an operator may declare a native (non-CAI) enumerator —\nin the `types:` list of each scope that needs it, swept only in that\nscope, since per-scope lists never inherit.\nCloud Asset Inventory does not model every GCP resource, and a type it\ncannot see is invisible to both gates at once, so such types are\nenumerated with a read-only `gcloud` command and merged into the same\ndenominator (`references/cai-blind-spots.md`). `inventory.py` ships a\nfrozen table of these for types known to be absent from the catalogue\nand applies them automatically — that is tool-owned, reviewed code, and\na model may no more edit it than any other frozen ruleset. A manifest\nblock covers a type the table does not, or overrides one. That command\nwidens what is checked rather than narrowing it, but it is still a\nhand-written piece of the denominator: `inventory.py` refuses non-read-only verbs, refuses\n`--filter`/`--limit` and friends, refuses a key template that is not\nunique, and stamps every sweep it ran into `inventory.json`'s\n`_meta.native_sweeps`. A model may draft the block; a human commits it.\n\nWaivers therefore carry an optional `signed_by` field. It is reported by\ndefault and enforced with `coverage.py --require-signed-waivers`.\nEnforcement is opt-in on purpose: demanding attribution where no human is\npresent produces invented signatures, and a forged name defeats the audit\nit was meant to support.\n\n## 2. Absolute invariants\n\n1. **NEVER run `terraform apply`** — against any environment, for any\n   reason. The generated authoritative IAM bindings make an apply\n   equivalent to overwriting live org IAM with a stale snapshot.\n2. **Read-only against GCP APIs.** Allowed: `list`, `describe`,\n   `get-iam-policy`, `search`, asset inventory reads, plus\n   `terraform init/validate/plan/show`. Never\n   `set/add/remove/update/create/delete`. Prefer a dedicated read-only\n   identity so the rule is enforced by IAM, not prose.\n3. **Never synthesize Terraform state.** Plan JSON assertions\n   (`verify_plan.py`) are the only accepted convergence evidence.\n4. **Org-confidential output.** Workspace contents and anything derived\n   from a real org (IDs, domains, principals, resource names, counts)\n   never enter a PUBLIC repository, an upstream pull request, or a\n   shareable document unsanitized. Fabric is used fork-and-own, so\n   during the OWN phase these artifacts legitimately live in the\n   operator's own private fork alongside the code — that is the expected\n   working directory, and the boundary to police is publication, not the\n   filesystem. Pulled `.tfstate` and saved `*.tfplan` are the exception\n   in kind, not degree: they contain secret VALUES (Secret Manager\n   payloads, generated keys) and are deleted once the manifest is\n   drafted and the plan verified.\n5. **Never rationalize residual diffs.** A diff believed benign becomes a\n   *proposed* `benign-drift.yaml` entry with evidence, reviewed by a\n   human. Until accepted, the run is red.\n\n## 3. Convergence definition\n\nA run is converged when, for every manifest-declared scope:\n\n1. `coverage.py` exits 0 — every in-scope inventory key is mapped to\n   emitted import blocks or human-waived; and\n2. `verify_plan.py` exits 0 — every planned change is a clean import,\n   no-op, or matches a reviewed benign-drift rule.\n\nBoth are required. The manifest bounds the claim, scope by scope:\nconvergence says nothing about types or levels a scope did not declare,\nand each scope declares its own list — so the run report must state the\nbound per scope, not as a single sentence about \"the manifest\".\n\n## 4. Incremental discipline\n\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable; re-runs extend the workspace for worklist entries only.\n- Genuine re-keying (renames, refactors) requires `moved {}` blocks and a\n  prominent note in the run report.\n- Manifest changes only ever widen or narrow scope; they never license\n  rewriting existing mappings.\n\n## 5. Toolchain\n\nTerraform >= 1.5; google + google-beta providers `>= 7.40, < 8`; Fabric\nmodule refs pinned to released tags (surface unreleased-feature\ntrade-offs to the user instead of silently pinning SHAs). Provider or\nFabric ref bumps invalidate `benign-drift.yaml` verification stamps and\nthe cookbook's address table — re-verify before trusting a verdict.\n","skills/fabric-importer/scripts/benign-drift.yaml":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED benign-drift ruleset for verify_plan.py.\n#\n# Every entry declares a class of plan diffs that is a provider/schema\n# artifact rather than real config-vs-reality drift. Entries require:\n#   - resource:        terraform resource type (\"*\" for any)\n#   - attributes:      top-level attribute paths covered\n#   - reason:          human-reviewed justification\n#   - verified_against: provider (and optionally terraform) version the\n#     justification was checked on. Re-verify on provider major bumps.\n#   - a bound on the DIFF (machine-enforced; an unbounded rule is\n#     ignored with a warning when it would otherwise have applied):\n#       when_after: {attr: value}          exact destination value\n#       when_after_matches: {attr: regex}  destination matches, one line\n#       when_after_computed: [attr, ...]   destination is FULLY unknown at\n#         or true                          plan time (provider-computed)\n#       when_unchanged: [attr, ...]        these attributes must not\n#                                          appear in the diff at all\n#     Every attribute in `attributes` needs a direct destination guard,\n#     OR the rule declares `when_unchanged` naming the live-affecting\n#     siblings that make the diff inert (the `terraform_labels` case:\n#     provider bookkeeping is harmless exactly while `labels` and\n#     `effective_labels` are untouched). `when_unchanged` must be\n#     disjoint from `attributes`.\n#     `when_before` alone is NOT sufficient. A rule that bounds only the\n#     live value accepts an arbitrary destination, so the gate would\n#     report CONVERGED for a plan that really writes to the resource on\n#     apply — the exact silent-pass this ruleset exists to prevent.\n#   Only the diff bound is machine-enforced; `reason` and\n#   `verified_against` are conventions a human reviewer upholds.\n#\n# Keep `attributes` as narrow as possible: changed paths are reported as\n# TOP-LEVEL keys, so listing a block attribute waives every future change\n# anywhere under it.\n#\n# The model operating the skill MUST NOT edit this file. verify_plan.py\n# prints a proposal template for unmatched updates; a human reviews the\n# diff and commits the entry.\n\nrules:\n  - resource: google_folder\n    attributes: [timeouts]\n    when_before:\n      timeouts: {}\n    when_after:\n      timeouts: null\n    reason: >-\n      Google provider v7 emits `timeouts {} -> null` in the import preview\n      for imported folders; the attribute is not a live setting and\n      disappears once state is persisted.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_logging_organization_sink\n    attributes: [description]\n    # Guard: only sinks whose LIVE description is the empty string are\n    # covered; a real description drift on a sink must stay residual.\n    when_before:\n      description: \"\"\n    # Guard: the destination must be the module default, which\n    # modules/organization/logging.tf builds as\n    # `coalesce(each.value.description, \"${each.key} (Terraform-managed).\")`.\n    # Any other new description is real drift and stays residual.\n    when_after_matches:\n      description: '[^\\n]{1,96} \\(Terraform-managed\\)\\.'\n    reason: >-\n      Live GCP log sinks with empty description (\"\") cannot round-trip through\n      Fabric's coalesce(v.description, default) which treats empty string as null\n      and applies the default description.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_compute_subnetwork\n    attributes: [send_secondary_ip_range_if_empty]\n    when_before:\n      send_secondary_ip_range_if_empty: null\n    when_after:\n      send_secondary_ip_range_if_empty: true\n    reason: >-\n      Fabric modules/net-vpc hardcodes send_secondary_ip_range_if_empty = true\n      on google_compute_subnetwork to ensure secondary range removals propagate,\n      which causes an in-place preview diff (null -> true) on imported subnets.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: \"*\"\n    attributes: [terraform_labels]\n    when_before:\n      terraform_labels: {}\n    # Guard: terraform_labels is provider bookkeeping, harmless exactly\n    # while the attributes that really carry labels to the API are\n    # untouched. If `labels` or `effective_labels` move too, this is a\n    # real label write and stays residual.\n    when_unchanged: [labels, effective_labels]\n    reason: >-\n      Google provider v7 computes `terraform_labels: {} -> {...}` during import\n      preview when default/inherited provider labels exist while\n      effective_labels and labels match live state with zero underlying mutation.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n","skills/fast/networking/SKILL.md":"---\nname: fast-networking\ndescription: Designs, customizes, and validates Cloud Foundation Fabric FAST Stage 2 (2-networking) YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a 2-networking dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a 2-networking dataset.\n---\n\n# FAST `2-networking` Dataset Designer\n\nThis skill guides users from functional networking and security requirements—or an attached reference diagram/IP table—to a complete, schema-validated Cloud Foundation Fabric FAST Stage 2 (`fast/stages/2-networking`) YAML dataset in a custom directory.\n\n## Core Principles & Execution Rules\n\n> [!IMPORTANT]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):**\n> For all design and customization tasks, prepend the following 3-phase progress block to **every** response so the user always sees the current phase and active step (if the user only asks to validate an existing dataset on disk, run `scripts/validate_dataset.py` directly and report the results):\n> ```text\n> FAST 2-Networking Designer Progress:\n> - Phase 1: Requirements Discovery\n>   (Step 1/2: Baseline & VPC Footprint - IN PROGRESS)\n> - Phase 2: Topology, IPAM & Plumbing Proposal\n>   (Not started)\n> - Phase 3: Dataset Generation & Validation\n>   (Not started)\n> ```\n\n> [!IMPORTANT]\n> **Understanding Turn Boundaries & Conditional Questioning (`ask_user` / `ask_question` / `question`):**\n> - Once you ask the user a question or present a specification for confirmation, **stop execution immediately (do not call more tools)**.\n> - Never simulate, guess, or assume the user's answer in the same turn. Wait for the user's next message.\n> - **NEVER ask a meta-question about how the user wants to start:** Never ask whether the user wants an interview vs. diagram import vs. cloning a dataset. If the user did not attach a diagram or name an existing dataset in their prompt, start **Turn 1** of the interview immediately (you may mention in one line of prose that they can share a diagram or dataset name at any time).\n> - **NEVER bundle dependent questions in the same turn or question tool call:** Only mutually **independent** questions may be asked together. You **MUST** resolve whether the user wants a **Single Shared VPC** vs. **Multi-VPC Hub & Spokes** *before* asking any questions about spoke-to-spoke routing, NCC hubs, peering, or inter-VPC transit NVAs.\n> - **Concrete Options Only:** Every multiple-choice option must be self-contained and actionable. Never offer a vague option like *\"Single region (e.g., europe-west8 or europe-west1)\"* that forces an extra follow-up turn just to ask *which* single region they meant; list the specific common region choices directly and allow custom free-text input.\n\n1. **Step-by-Step by Default, Adaptive on Rich Input:**\n   - **Default Mode (Conditional Interview DAG):** When the user starts with a general request (e.g., *\"Help me define my FAST networking dataset\"*), follow the Conditional Interview DAG in `references/1-requirements-to-topology.md`, asking only the questions that apply to the user's chosen branch (`2` turns for Single Shared VPC; `3` turns for Multi-VPC Hub & Spokes) and recommending sensible FAST defaults.\n   - **Adaptive Mode (Diagram, IP Table, or Detailed Spec Provided):** When the user provides an architecture diagram (PNG/SVG/PDF/Draw.io), an IP table, or a detailed prompt upfront, extract all stated facts immediately, mark **Phase 1** completed, jump straight to **Phase 2 (Topology, IPAM & Plumbing Proposal)** with pre-filled defaults for any unspecified CIDRs/ASNs, and ask for a single sign-off before generating files.\n   - **Validation-Only Mode:** When the user points to an existing dataset directory and asks to validate or test it, run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the findings.\n   - **Zero-Redundancy Rule:** Never ask the user to repeat a region, CIDR, environment name, connectivity choice, or workload type they already provided in their prompt or diagram.\n\n2. **Stage 2 Boundary (\"Plumbing vs. Downstream Consumption\"):**\n   - `fast/stages/2-networking` provisions **foundational network plumbing only**: networking host projects, VPCs, subnets (primary, secondary, `proxy_only`, `psc`, `private_nat`), `psa_configs`, reserved addresses, Cloud Routers, Cloud NAT, VPC Peering, NCC hubs/spokes, HA VPNs, Interconnect VLAN attachments, NVA instances/ILBs/BGP spokes, Cloud DNS zones/response policies, and hierarchical/network/VPC firewall policies.\n   - Higher-level security and application resources—such as **Cloud NGFW Enterprise** (`firewall_endpoint` and `security_profile_group`, typically managed in a `2-networking-ngfw` or security stage), **Application Load Balancers** (`net-lb-app-*`), **GKE clusters**, **Cloud SQL / AlloyDB / NetApp instances**, and **PSC Producer `ServiceAttachment`s**—are deployed in **follow-up stages** (`2-project-factory` or workload/security stages).\n   - Whenever the user requests one of these capabilities, configure the required Stage 2 plumbing (e.g., `/23` `proxy_only: true` subnets, `psc: true` subnets, `psa_configs`, `$security_profile_groups:` rules in `firewall-policies/`) and record the downstream handoff in the generated dataset's `README.md`. Never invent unsupported YAML keys inside `2-networking`.\n\n3. **Custom Dataset Output Target:**\n   - Never overwrite the in-tree reference datasets under `fast/stages/2-networking/datasets/` unless explicitly developing/updating Fabric itself.\n   - Always generate the user's dataset into a dedicated custom directory (e.g., `./fast-config/2-networking/data/` or a user-specified path) along with a ready-to-use `2-networking.auto.tfvars` file that maps `factories_config` to that directory.\n\n4. **Mandatory Mechanical Validation Gate:**\n   - Never declare a generated or modified dataset complete until you have run `scripts/validate_dataset.py` against the target directory (checking JSON schemas, `$token` reference integrity, intra-VPC and cross-VPC CIDR overlaps, and routing/security guardrails, plus `terraform validate` and `terraform plan` when Terraform is available).\n\n---\n\n## Workflow Map\n\nBefore executing a phase, read its corresponding reference document for the decision tables, YAML patterns, and guardrails.\n\n### Phase 1: Requirements Discovery\n*Description:* Walk the Conditional Interview DAG (or extract facts from an attached diagram/prompt), forking cleanly between **Single Shared VPC** and **Multi-VPC Hub & Spokes**.\\\n*Reference: [Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- **Step 1:** Stage 0 Integration, Concrete Target Regions & Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`)\n- **Step 2:** Branch-Specific Requirements Discovery (`Step 2` Single-VPC Plumbing on Path A; `Step 2a` Spoke Scope/Inspection + `Step 2b` Hybrid/Egress/Plumbing on Path B)\n\n### Phase 2: Topology, IPAM & Plumbing Proposal (Single Sign-Off Gate)\n*Description:* Map the functional requirements to a **Single Shared VPC** layout or one of the canonical `2-networking` hub-and-spoke topologies (`ncc`, `nva-ncc`, `nva`, `peerings`, `vpns`), pre-fill sensible non-overlapping default CIDRs, BGP ASNs, DNS zones, and Stage 2 plumbing in a single unified blueprint table, and ask the user once to confirm (or tweak) the proposal and output directory.\\\n*References:*\n- *[Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- *[Routing and Security Mechanisms](references/2-routing-and-security-mechanisms.md)*\n- *[Stage Boundaries and Plumbing](references/3-stage-boundaries-and-plumbing.md)*\n- *[Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 3:** Unified Topology, Pre-Filled Regional IPAM/BGP/DNS Defaults, Output Path & Downstream Handoff Sign-Off\n\n### Phase 3: Dataset Generation & Validation\n*Description:* Generate the YAML dataset and `2-networking.auto.tfvars` in the user's custom output directory, run `scripts/validate_dataset.py` (`terraform validate` and `terraform plan`), and write the dataset `README.md`.\\\n*Reference: [Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 4:** YAML Dataset & `2-networking.auto.tfvars` Generation\n- **Step 5:** Mechanical Validation (`scripts/validate_dataset.py`, including `terraform validate` and `terraform plan`)\n- **Step 6:** Dataset `README.md` & Follow-up Stage Handoff Summary\n","skills/fast/networking/references/1-requirements-to-topology.md":"# Phase 1 & 2 Reference: Requirements Discovery & Unified Topology/IPAM Proposal\n\nThis reference governs **Phase 1 (Requirements Discovery)** and **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Rather than asking the user to pick a FAST dataset name upfront—or dumping unrelated questions in static blocks—guide them through a **Conditional Decision DAG** and then present a **single unified Topology + Pre-Filled IPAM Proposal** for confirmation before generating YAML.\n\n---\n\n## 1. Silent Entry Detection (Never Ask a Workflow Meta-Question)\n\nInspect the user's first message and any attached files silently—**never** ask the user a multiple-choice question about how they want to start (interview vs. diagram vs. clone):\n\n- **If the user starts with a general design request (no diagram or detailed spec attached):** Start **Turn 1** of the Conditional Interview DAG (Section 2) immediately. You may include a brief one-line note in prose (*\"If you have an architecture diagram, IP table, or existing dataset you'd like to adapt, feel free to share it at any point\"*), then ask the Turn 1 baseline questions.\n- **If the user attaches or points to an architecture diagram (`.png`, `.jpg`, `.svg`, `.pdf`, `.drawio`) or an IP/subnet table / detailed prompt:**\n  1. Extract all stated facts (Projects, Single VPC vs. Hub & Spokes, regions, subnet CIDRs, interconnect/VPN links, NVAs, NCC hubs, peerings, DNS zones) and infer the required Stage 2 plumbing (e.g., GKE secondary ranges, `/23` `proxy_only: true` subnets for ALBs, `psa_configs` for Cloud SQL/AlloyDB, `$security_profile_groups:` for Cloud NGFW Enterprise).\n  2. Mark **Phase 1** completed and jump straight to **Phase 2 (Unified Topology, IPAM & Plumbing Proposal)** (Section 4), pre-filling sensible defaults for any unspecified CIDRs/ASNs and highlighting only the remaining ambiguities for confirmation.\n- **If the user asks to clone & mutate an existing in-tree dataset:** Jump straight to **Phase 2 (Section 4)** showing the proposed delta and IPAM table against `fast/stages/2-networking/datasets/<name>/`.\n- **If the user only asks to validate an existing dataset on disk:** Run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the results without running the interview.\n\n---\n\n## 2. Phase 1: Conditional Interview DAG (Step-by-Step Discovery)\n\nFollow this conditional flow so you never ask moot questions (such as asking about hubs and spokes when the user selected a Single Shared VPC):\n\n```text\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork                     │\n  │ (Ask together ONLY if not already stated in the user's initial prompt)     │\n  │  1. Stage 0 Integration (`0-org-setup` outputs vs. Standalone `.tfvars`)   │\n  │  2. Concrete Target Region(s) (`1..N` specific regions + shortname keys)   │\n  │  3. VPC Footprint Fork:                                                    │\n  │     • Path A: Single Shared VPC (1 VPC, microsegmented via FW/Tags/NGFW)   │\n  │     • Path B: Multi-VPC Hub & Spokes (Separate VPCs for IAM / quota scale) │\n  └───────────────────┬────────────────────────────────────┬───────────────────┘\n                      │                                    │\n         [Path A: Single Shared VPC]          [Path B: Multi-VPC Hub & Spokes]\n         (SKIP all hub/spoke/NCC/peering       ▼\n          and transit-NVA questions!)     ┌────────────────────────────────────┐\n                      │                   │ Turn 2B (Step 2a): Multi-VPC Scope │\n                      │                   │  1. Which spoke environments?      │\n                      │                   │  2. Inter-VPC reachability &       │\n                      │                   │     transit inspection model?      │\n                      │                   └────────────────┬───────────────────┘\n                      │                                    │\n                      └─────────────────┬──────────────────┘\n                                        ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 2A (if Path A) / Turn 3B (if Path B) (Step 2b):                       │\n  │ Connectivity, Security & Workload Plumbing (tailored to Path A or Path B)  │\n  │  1. Hybrid Connectivity (None / HA VPN / Cloud Interconnect / Both)        │\n  │  2. Internet Egress (Cloud NAT / Air-gapped [or Centralized via NVA])      │\n  │  3. Firewall Posture (Cloud-Native L4 Policies vs. Cloud NGFW Enterprise)  │\n  │  4. Workload Plumbing (None / GKE / L7 ALB `proxy_only` / PSA / PSC / etc) │\n  └────────────────────────────────────┬───────────────────────────────────────┘\n                                       ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Phase 2 (Step 3 — Single Unified Sign-Off Gate):                           │\n  │ Present Topology + Pre-Filled Regional IPAM/ASN/DNS Defaults + Output Path │\n  │ (User confirms defaults or tweaks CIDRs/names -> Proceed to Phase 3 Gen)   │\n  └────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork\nAsk only the following mutually independent questions (skipping any already answered in the user's prompt):\n\n1. **FAST `0-org-setup` Integration vs. Standalone Stage Execution:** Are you running `2-networking` on top of an existing FAST `0-org-setup` deployment, or standalone?\n   - *Why this matters:* `2-networking` can create new projects or reuse existing projects in either mode. Knowing if `0-org-setup` is used is strictly for **tfvars, provider, and context plumbing** (whether `0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` provide `$folder_ids`, `$storage_buckets`, `billing_account`, and `prefix`, or whether those variables must be supplied directly in `2-networking.auto.tfvars`).\n2. **Target Regions (`1..N`) & Location Keys:** Which specific GCP region(s) (`1` to `N`) will host your resources?\n   - *Important:* Present **concrete** single-region and multi-region choices (e.g., `europe-west1 + europe-west8`, `europe-west8 + europe-west12`, `europe-west8 (single region)`, `europe-west1 (single region)`, `us-central1 + us-east4`, or custom free-text) so you never need a second question to ask *which* region they picked.\n   - *Note on `defaults.yaml` location keys:* `primary` and `secondary` in the reference datasets are just arbitrary key names in `context.locations`. A dataset can define `1` to `N` regions using either role names (`primary`, `secondary`) or concise region shortnames (`ew1`, `ew8`, `ew12`, `usc1`). Propose clean shortname keys matching their chosen regions.\n3. **Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`):**\n   - *Framing to include in the question:* In GCP, network microsegmentation and L7 IPS (via Hierarchical/Network Firewall Policies, IAM-governed Secure Tags, and Cloud NGFW Enterprise) work **inside a single Shared VPC**—you do not need multiple VPCs for network security isolation. Multiple VPCs are used to **isolate IAM/administrative blast radii** across host projects (e.g., `dev` vs. `prod`) and to **scale horizontally** across VPC quota domains.\n   - **Option A — Single Shared VPC:** A single Shared VPC (`main-0` / `shared-0`) hosting all environments/subnets, segmented via subnet IAM, Firewall Policies, Secure Tags, and optional Cloud NGFW Enterprise. *(Ideal for PoCs, simple/compact estates, or low-latency enclaves—no hubs, spokes, NCC, or peering required).*\n   - **Option B — Multi-VPC Hub & Spokes:** Separate Shared VPCs per environment (`dev`, `prod`, etc.) interconnected through a central hub (via NCC, 3rd-party NVAs, or Peering).\n\n> **STOP after Turn 1 and wait for the user's answer before asking Turn 2 questions.**\n\n---\n\n### Turn 2 — Path A (`Single Shared VPC` Selected): Single-VPC Plumbing Discovery\nWhen the user selects **Option A (Single Shared VPC)** in Turn 1:\n- **Do NOT ask about spoke environments, spoke-to-spoke routing, NCC hubs, VPC peering, or inter-VPC transit NVAs.**\n- Ask only the Single-VPC questions below:\n  1. **Environment Subnets within the Single VPC:** Do you want a single general-purpose subnet per region, or separate subnets per logical environment (e.g., `dev` and `prod` subnets within the same Shared VPC for subnet-level IAM delegation)?\n  2. **Hybrid Connectivity:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)** terminating in this VPC? *(If Cloud Interconnect is selected, note that Phase 2 will confirm Dedicated vs. Partner mode, peer ASN, and interconnect/VLAN attachment parameters).*\n  3. **Internet Egress:** Should the VPC egress via **Cloud NAT** (`nat_config`), or have **no internet egress** (Air-gapped / Private Google Access only)?\n  4. **Firewall & Inspection Posture:** Are **Cloud-Native L4 Firewalls** (Hierarchical + VPC Firewall Policies) sufficient, or do you also want **Cloud NGFW Enterprise** (L7 IPS / TLS inspection rules via `$security_profile_groups`—or intra-VPC 3rd-party NVA hairpinning via Policy-Based Routing)?\n  5. **Workload & Managed Service Plumbing:** Which platforms will run in this VPC so Stage 2 can pre-provision their subnets/ranges? *(Include an explicit **\"None (standard VM subnets only)\"** option alongside **GKE Clusters** (`pods`/`services` secondary ranges), **Application Load Balancers** (`/23` `proxy_only: true` subnet), **Private Services Access / PSA** (`psa_configs` for Cloud SQL/AlloyDB/Memorystore/NetApp), **Private Service Connect / PSC** (`psc: true` NAT subnets or `psc:` consumer endpoints in `addresses.yaml`), and **Serverless / Vertex AI** (`/24` Direct VPC Egress / `network_attachments`))*.\n\n---\n\n### Turn 2 — Path B (`Multi-VPC Hub & Spokes` Selected): Inter-VPC & Inspection Discovery\nWhen the user selects **Option B (Multi-VPC Hub & Spokes)** in Turn 1, ask:\n\n1. **Spoke Environments:** Which workload environments need dedicated spoke Shared VPCs? *(Default: `dev` and `prod`; common additions: `nonprod`/`uat`, `dmz`)*.\n2. **Inter-VPC Reachability & Transit Inspection Pattern:** How should your spoke VPCs communicate with each other and with shared services/hybrid links?\n   - **Option A — Direct Spoke-to-Spoke over NCC (`hub-and-spokes-ncc` with `MESH`, Recommended Cloud-Native Default):** Full line-rate routing across spokes via NCC `MESH`, with microsegmentation enforced by Firewall Policies, Secure Tags, and/or **Cloud NGFW Enterprise**.\n   - **Option B — Central Hub Group + Isolated Edge Spokes over NCC (`hub-and-spokes-ncc` with `STAR`):** Shared-service/hub VPCs in the `center` group are reachable by all `edge` spokes (`dev`, `prod`), while `edge` spokes are isolated from each other at the routing table layer (strongly favored over traditional peering for scale and transitive hybrid/PSA/PSC routing).\n   - **Option C — Inspected via 3rd-Party Stateful NVA VMs (Palo Alto, Fortinet, Check Point, Linux/FRR):** Spoke-to-spoke and hybrid traffic is hairpinned through multi-NIC NVA VMs.\n     - **Always recommend `hub-and-spokes-nva-ncc` (NCC Router Appliance with dynamic BGP) for multi-regional architectures:** It is the **only** pattern that provides automatic cross-region failover via BGP/MED policies *and* allows managed services (such as PSA-peered Cloud SQL, AlloyDB, or NetApp) to use the best available regional route, since managed services do not support network-tagged static routes.\n     - Use **`hub-and-spokes-nva` (ILB Sandwich with static routes)** only for single-region deployments or specific point-to-point/DMZ `ILB-as-next-hop` legs where BGP is not used.\n   - **Option D — Strictly Isolated Spokes via VPC Peering (`hub-and-spokes-peerings`):** Small static estate (`<= 5` spokes, `$0` hub fee) where `dev` and `prod` peer only with `hub`.\n     - *Advisory warning:* Remind the user that **\"strict spoke isolation\" almost never holds true over time**—scenarios like exchanging anonymized data between `prod` and `dev`, or running shared CI/CD or AI agents that must operate across both environments, will bite them under non-transitive peering.\n\n### Turn 3 — Path B (`Multi-VPC Hub & Spokes`): Hybrid, Egress, Firewall & Workload Plumbing\nOnce the Multi-VPC pattern is selected in Turn 2B, ask the remaining plumbing questions:\n1. **On-Premises / Multicloud Links:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)**?\n   - *(Note: On NCC hybrid spokes, `site_to_site_data_transfer` defaults to `true` in `2-networking`; ask whether external sites need site-to-site transit through GCP (`true`, default) or only communicate with GCP VPCs (`false`, which also avoids errors in regions where S2S is unsupported). If Cloud Interconnect is selected, gather or propose Dedicated vs. Partner mode and `peer_asn`).*\n2. **Internet Egress Model:** Per-VPC **Cloud NAT** (`nat_config`), centralized egress through the NVA/DMZ VPC, or **no internet egress** (Air-gapped / PGA only)?\n3. **Cloud NGFW Enterprise (if Cloud-Native routing was selected in Turn 2B):** Standard L4 Firewall Policies (`goto_next`), or add **Cloud NGFW Enterprise** (`$security_profile_groups` L7 IPS rules)?\n4. **Workload & Managed Service Plumbing:** **None (standard subnets only)**, **GKE** (`pods`/`services` secondaries), **L7 ALB** (`/23` `proxy_only: true`), **PSA** (`psa_configs`), **PSC** (`psc: true` NAT subnets / `psc:` consumer endpoints in `addresses.yaml`), and/or **Serverless / Vertex AI**?\n\n---\n\n## 3. Mapping Requirements to `2-networking` Topologies\n\n```text\n  [Primary VPC Footprint Fork: Single VPC or Multi-VPC Hub & Spokes?]\n   ├── Single Shared VPC (PoCs, simple estates, or single IAM boundary)\n   │    └──> Custom Single-VPC Dataset (1 project + 1 VPC, no NCC/Peering/DNS-peering; derived from peerings dataset)\n   │\n   └── Multi-VPC Hub & Spokes (Separate host-project IAM boundaries or horizontal quota scale)\n        ├── [Do inter-VPC or hybrid flows require 3rd-party multi-NIC NVA VMs as the transit router?]\n        │    ├── Multi-region NVA (Recommended Default) ──> datasets/hub-and-spokes-nva-ncc (BGP Router Appliance)\n        │    ├── Single-region NVA / Static ILB only ─────> datasets/hub-and-spokes-nva (ILB Sandwich)\n        │    └── Hybrid (NCC-RA core + ILB DMZ leg) ──────> datasets/hub-and-spokes-nva-ncc (+ ilb_config on DMZ NIC)\n        │\n        └── [Cloud-native routing, with L4 Firewall Policies and/or Cloud NGFW Enterprise]\n             ├── Recommended Default (Any scale, hybrid/PSA/PSC transitivity) ──> datasets/hub-and-spokes-ncc\n             │    ├── Full spoke-to-spoke routing (microsegmented via FW/NGFW) ─> preset_topology: MESH\n             │    └── Routing-level isolation between edge spokes via center ───> preset_topology: STAR\n             │\n             ├── Small static estate (<= 5 spokes, $0 hub fee) ─────────────────> datasets/hub-and-spokes-peerings\n             │    (Warn user: strict isolation almost always bites later—e.g. exchanging anonymized prod/dev data or running agents across both envs)\n             │\n             └── Legacy / Niche (Not recommended for new designs; NCC wins) ────> datasets/hub-and-spokes-vpns\n```\n\n### Topology Comparison Matrix\n\n| Topology Pattern & In-Tree Baseline | Core Connectivity & VPC Layout | East-West Behavior | Scale & Quota Envelope | Best Fit & Recommendation Posture | Watch Out For... |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| **Single Shared VPC** *(Derived by simplifying `hub-and-spokes-peerings` to 1 project + 1 VPC)* | Single Shared VPC (`vpcs/main/` or `vpcs/shared/`) in 1 host project (`projects/net-core-0.yaml`). Zero `ncc-hubs/`, `nvas/`, or `peering_config`. | **Native intra-VPC routing** at full line rate; segmented via subnet IAM, Firewall Policies, Secure Tags, and/or Cloud NGFW Enterprise. | Up to **300 subnets** and **100–1,000 service projects** in a single quota domain. | **Ideal for PoCs, simple/compact estates, or low-latency enclaves** where a single network admin team manages the host project. | All environments share a single Shared VPC host project IAM boundary and VPC quota domain; intra-VPC 3rd-party appliance hairpinning requires Policy-Based Routing (`policy_based_routes`). |\n| **`hub-and-spokes-ncc`** *(Recommended Multi-VPC Cloud-Native Default)* | Central NCC Hub (`hub.yaml`) connecting `hub-0` (with hybrid VPN/VLAN spokes), `dev-0`, and `prod-0` as VPC spokes. | **Configurable:** Full any-to-any with `preset_topology: MESH`, or isolated edge spokes with `preset_topology: STAR` (`center` vs. `edge` groups). | Scales up to **250 VPC spokes** per hub (`4,000` subnet routes; `1,000` dynamic prefixes/region; `1,000` propagated PSC endpoints). | **Primary recommendation** for multi-VPC cloud-native and Cloud NGFW Enterprise estates; native transitive hybrid routing, PSC propagation (`export_psc: true`), and Producer VPC Spoke support. | `preset_topology` (`MESH` vs. `STAR`) is immutable after hub creation; `site_to_site_data_transfer` on hybrid spokes defaults to `true` (set `false` in regions that do not support S2S). |\n| **`hub-and-spokes-nva-ncc`** *(Recommended Multi-Region NVA Default)* | Dual NCC Hubs (`routing` `MESH` + `transit` `STAR`); multi-region NVAs (`nvas/primary.yaml`, `secondary.yaml`) attached as **NCC Router Appliance** spokes (`ncc_ra_config` + `bgp_config`). | **Inspected:** `dev-0` and `prod-0` attach to `transit` `edge` group; NVAs attach to `transit` `center` and `routing` `default`, inspecting all N-S and E-W flows over dynamic BGP. | Scales to NCC limits (**250 spokes**) with zero static routes and automatic intra-region / cross-region BGP failover. | **Always recommend for multi-region 3rd-party NVA architectures.** Only way to get automatic cross-region failover and let managed services (`PSA`) follow the best available route (since managed services do not support tagged static routes). | Never place workloads in the `transit` VPC (asymmetric return path); every prefix in `advertise` (control plane) must have a matching kernel route in `routes` (data plane). |\n| **`hub-and-spokes-nva`** | Multi-NIC NVA (`nvas/main.yaml`) with `ilb_config` directly attached via 4 NICs to `dmz`, `hub-0`, `dev-0`, and `prod-0` (no `peering_config` between VPCs). | **Inspected:** Static routes in each VPC steer inter-VPC and hybrid traffic to the NVA's per-VPC Internal Passthrough NLB VIP (`$addresses:<vpc>/main`). | Bounded by GCE maximum NICs per VM (`2` to `8` vNICs depending on machine shape, so at most 8 directly attached VPCs) and NVA VM bandwidth. | Single-region 3rd-party NVA deployments with $\\le 8$ VPCs, or specific `ILB-as-next-hop` DMZ/cross-environment legs where dynamic BGP is not used. | Static routes lack regional proximity metrics for multi-region failover; requires symmetric 5-tuple hashing (`PER_CONNECTION`, `session_affinity: NONE`, `NEVER_PERSIST`) and a shared health check. |\n| **`hub-and-spokes-peerings`** *(also symlinked as `classic`)* | `hub-0` peered via `peering_config` with `dev-0` and `prod-0` (`routes_config.export/import: true`). | **Isolated** (VPC Peering is strictly non-transitive). | Small estates (**$\\le 5$ spokes**). Shares peering-group quotas (`subnet_ranges_per_peering_group` = 1,000; `static_routes_per_peering_group` = 300). | Small, cost-sensitive setups (`$0` hub fee). **Caution:** Warn the user that \"strict spoke isolation\" is almost never true long-term—scenarios like exchanging anonymized data between `prod` and `dev` or running agents that work across both environments will bite them under non-transitive peering. | On-premises routes in `hub-0` cannot transit to PSA (`servicenetworking`) peered to `dev-0`/`prod-0`; Cloud DNS forwarding (`35.199.192.0/19`) cannot hop across peering to a VPN in another VPC. |\n| **`hub-and-spokes-vpns`** *(Legacy / Niche)* | `hub-0` connected to `dev-0` and `prod-0` via GCP-to-GCP HA VPN tunnels (`vpcs/<vpc>/vpns/*.yaml`) and Cloud Router BGP. | **Policy-Controlled:** Spokes exchange routes through `hub-0` Cloud Routers, filtered via CEL `route_policies` (`IMPORT`/`EXPORT`). | Up to 5 HA VPN gateways/region/project; bandwidth capped at **1.5–3 Gbps per IPsec tunnel** (`1460` byte MTU). | **Avoid for modern greenfield designs (NCC wins on throughput, cost, and simplicity).** Retain only for legacy brownfield compatibility or hard cross-org IPsec requirements. | IPsec tunnel throughput ceiling and MTU clamping (`1460` / `1406`); VPC firewall rules must allow `icmp` ingress so Path MTU Discovery (`ICMP Type 3 Code 4`) functions. |\n\n---\n\n## 4. Phase 2 (Step 3): Unified Topology, IPAM & Plumbing Proposal Template\n\nDo **not** split Topology Confirmation and IPAM Confirmation into two separate turns. As soon as Phase 1 requirements are gathered (or extracted upfront in Adaptive Mode), present a **single unified proposal** containing both the selected topology and pre-filled default CIDRs/ASNs/DNS domains, and ask the user once to confirm (or specify any custom CIDRs/names/output path) before generating the dataset in Phase 3:\n\n```markdown\n### Proposed FAST `2-networking` Topology & IPAM Blueprint\n\n- **Topology Pattern:** [`Single Shared VPC` | `datasets/<chosen-dataset>`]\n- **Target Output Directory:** `<proposed-or-user-specified-path>` *(e.g. `./fast-config/2-networking/data/`)*\n- **Stage 0 Integration:** [FAST `0-org-setup` (`$folder_ids:networking`) | Standalone `.tfvars`]\n- **Regions (`context.locations`):** `<key-1>` = `<region-1>` [, `<key-2>` = `<region-2>`, ...]\n\n#### 1. Proposed Projects, VPCs & Regional IPAM Allocation (Pre-Filled Defaults)\n| Project Key (`projects/`) | VPC Key (`vpcs/`) | Region (`$locations:`) | Subnet / Range Name | Proposed CIDR | Purpose & Stage 2 Plumbing |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| `net-core-0` | `main` (or `hub` / `routing`) | `<key-1>` | `<subnet-name>` | `10.64.0.0/24` | [Primary subnet / HA VPN / Cloud NAT / DNS] |\n| *(Additional rows for secondary regions, `proxy_only`, `psc`, `psa_configs`, GKE `pods`/`services`, and spoke VPCs as applicable)* | | | | | |\n\n#### 2. Routing, BGP, DNS & Firewall Summary\n- **East-West & Hybrid Routing:** [1–2 bullets including proposed Cloud Router / Peer BGP ASNs if VPN/Interconnect/NCC-RA is used]\n- **Cloud DNS & Firewall Posture:** [Proposed private/forwarding/peering DNS domains (`gcp.example.com.`, `onprem.example.com.`), RPZ, and Hierarchical/VPC Firewall rules]\n- **Downstream Stage Handoffs (Out of Scope for Stage 2):** [List any requested ALBs, Cloud NGFW Enterprise endpoints/profiles, GKE clusters, or Cloud SQL instances that will consume the Stage 2 plumbing in follow-up stages, or `None`]\n```\n\nAsk the user to confirm this unified blueprint (or tell you any CIDRs, ASNs, domain names, Interconnect details, or output path they want changed) before executing **Phase 3 (Dataset Generation & Validation)**.\n","skills/fast/networking/references/2-routing-and-security-mechanisms.md":"# Phase 2 Reference: Routing and Security Mechanisms Across Topologies\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Once the baseline topology is selected in `1-requirements-to-topology.md`, use this guide to explain and configure how North-South, East-West, Hybrid, Egress, DNS, and Inspection mechanisms work in YAML.\n\n---\n\n## 1. Cross-Topology Mechanism Matrix\n\n| Mechanism | `Single Shared VPC` *(Simple / PoC / Single-IAM)* | `hub-and-spokes-ncc` *(Multi-VPC Cloud-Native Default)* | `hub-and-spokes-nva-ncc` *(Multi-Region NVA Default)* | `hub-and-spokes-nva` *(Single-Region ILB Sandwich)* | `hub-and-spokes-peerings` *(Small Static Estate)* | `hub-and-spokes-vpns` *(Legacy / Niche)* |\n| :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n| **Spoke-to-Hub Routing** | N/A (single VPC `vpcs/main/`; no hub or peering needed). | `ncc_config` on hub & spokes (`hub: $ncc_hubs:hub`). | Dual NCC hubs (`routing` `MESH` + `transit` `STAR`); spokes attach to `transit` `edge` group. | Direct multi-NIC NVA attachment across `dmz`, `hub`, `dev`, `prod` + static routes to `$addresses:<vpc>/main` (no VPC peering). | Reciprocal `peering_config` on both hub & spoke (`routes_config.export/import: true`, enabled by default). | `vpcs/<vpc>/vpns/*.yaml` with reciprocal `$vpn_gateways:` + BGP. |\n| **East-West / Spoke Routing** | **Native intra-VPC routing**; isolated via Firewall Policies, Secure Tags, or Cloud NGFW. | **Direct** (`MESH` preset) or **Blocked at routing layer** (`STAR` `edge` group). | **Inspected via NVA:** Spokes in `transit/edge` route through NVAs in `transit/center` via BGP origination (`advertise` + `routes`). | **Inspected via NVA:** Spoke static routes point inter-spoke prefixes to the local spoke ILB VIP (`$addresses:<spoke>/main`). | **Blocked by design** (VPC Peering is strictly non-transitive). | Filtered via Cloud Router BGP `route_policies` (`IMPORT`/`EXPORT`) in `hub`. |\n| **On-Prem Hybrid Transit** | Terminates directly in `main` (`vpcs/main/vpns/` or `vlan-attachments/`); zero transitivity hops. | Terminates in `hub`; `ncc_spoke_config` on VPN/VLAN attaches tunnels/VLANs as NCC hybrid spokes. | Terminates in `routing` VPC on `routing` `MESH` hub; NVA inspects between `routing` and `transit` hubs. | Terminates in `dmz` or `hub` VPC; static routes steer traffic through NVA ILBs to spokes. | Terminates in `hub`; spokes import custom routes via peering (`routes_config.import: true`). | Terminates in `hub`; re-advertised to spokes over inter-VPC BGP. |\n| **Internet Egress** | `nat_config` (Cloud NAT) and PGA on `main`. | Per-VPC `nat_config` (Cloud NAT) and Private Google Access (`PGA`). | Per-VPC `nat_config` or centralized `0.0.0.0/0` originated on `routing` CR $\\rightarrow$ NVA (`masquerade_exclude`) $\\rightarrow$ Cloud NAT. | Per-VPC `nat_config` or centralized default route via NVA to `dmz`/`untrusted` NIC (`masquerade: true`) + Cloud NAT. | Per-VPC `nat_config` (Cloud NAT) and PGA. | Per-VPC `nat_config` or default route over hub VPN. |\n| **Firewall & Inspection** | Hierarchical policy (`goto_next`) + VPC rules + Secure Tags (+ optional Cloud NGFW or PBR). | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Stateful 3rd-party NVA inspection via BGP RA spokes (active-passive MED or active-active ECMP) + VPC/Hierarchical rules. | Stateful 3rd-party NVA inspection via ILB next-hop (`PER_CONNECTION`, `NONE` affinity, `NEVER_PERSIST`) + VPC/Hierarchical rules. | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Hierarchical policy + VPC firewall rules + BGP prefix filtering. |\n| **Cloud DNS Pattern** | All `private` and `forwarding` zones bind directly to `client_networks: [$networks:main]` (no DNS peering needed). | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub` + RPZ. | Per-spoke private zones bound to spoke + `routing`; `routing` hosts on-prem forwarding; `transit` & spokes peer `.` to `routing` + RPZ. | Per-spoke private zones bound to spoke + `hub`; `dmz` & spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; spokes peer `.` to `hub`. |\n\n---\n\n## 2. Cloud-Native Routing Mechanics (`single-vpc`, `ncc` & `peerings`)\n\n### 2.0 Single Shared VPC (`single-vpc`)\n- **Dataset Structure:** Derived by simplifying `hub-and-spokes-peerings` to a single host project (`projects/net-core-0.yaml` with `shared_vpc_host_config.enabled: true`) and a single VPC (`vpcs/main/.config.yaml`). Omit `ncc-hubs/`, `nvas/`, `peering_config`, and `ncc_config`.\n- **Environment Segmentation without Multiple VPCs:**\n  - Create either general-purpose regional subnets or per-environment regional subnets (e.g., `vpcs/main/subnets/dev-ew8.yaml`, `prod-ew8.yaml`) inside `vpcs/main/subnets/`, delegating subnet-level `roles/compute.networkUser` IAM to downstream service projects.\n  - Enforce East-West isolation between environments or tiers using Hierarchical/VPC Firewall Policies, IAM-governed Secure Tags (`$tag_values:`), and optional Cloud NGFW Enterprise (`$security_profile_groups:`).\n- **Direct Hybrid, PSA & DNS Simplicity:**\n  - HA VPNs (`vpcs/main/vpns/`), VLAN attachments (`vpcs/main/vlan-attachments/`), and Private Services Access (`psa_configs`) all attach directly to `main`, eliminating inter-VPC transitivity constraints.\n  - All Cloud DNS `private` and `forwarding` zones in `dns/zones/` bind directly to `client_networks: [$networks:main]`, and `dns/response-policies/*.yaml` lists only `networks: [$networks:main]`—do **not** create a `peer-root.yaml` DNS peering zone in a Single VPC dataset.\n\n### 2.1 Network Connectivity Center (`hub-and-spokes-ncc`)\n- **`MESH` vs. `STAR` Preset Topology (`ncc-hubs/<hub>.yaml`):**\n  - `preset_topology: MESH` (default) places all spokes in `groups.default`, enabling direct line-rate routing between every spoke (microsegmented via Firewall Policies, Secure Tags, or Cloud NGFW Enterprise).\n  - `preset_topology: STAR` creates two groups (`center` and `edge`). Attach shared-service/transit VPCs to `$ncc_groups:<hub>/center` and workload environment VPCs (`dev`, `prod`) to `$ncc_groups:<hub>/edge` when you want routing-table isolation between workload spokes while sharing central services.\n  - *Constraint:* `preset_topology` is immutable after hub creation.\n- **PSC Propagation:** `export_psc` defaults to `true` in `factory-ncc.tf` so Private Service Connect consumer endpoints created in one spoke (such as `hub`) are automatically reachable from all connected VPC spokes.\n- **NCC Hybrid Spokes (`vpns/*.yaml` & `vlan-attachments/*.yaml`):**\n  - Adding `ncc_spoke_config: { hub: $ncc_hubs:hub }` to a VPN or VLAN attachment file registers it as an NCC hybrid spoke (`factory-ncc.tf` automatically configures `include_import_ranges = [\"ALL_IPV4_RANGES\"]` so Cloud Routers import dynamic routes from the hub and advertise spoke subnets back to on-premises peers).\n  - **`site_to_site_data_transfer` (`true` default vs. `false`):** In `factory-ncc.tf`, `vpn.schema.json`, and `vlan-attachments.schema.json`, `site_to_site_data_transfer` **defaults to `true`**, allowing two hybrid spokes attached to the hub (for example, an On-Premises VPN/Interconnect and a colocated Partner Interconnect) to exchange routes and traffic directly through Google Cloud. Explicitly set `site_to_site_data_transfer: false` when external sites only communicate with GCP VPCs or when deploying in a GCP region that does not support NCC site-to-site data transfer.\n\n### 2.2 VPC Network Peering (`hub-and-spokes-peerings`)\n- **Reciprocal Declaration:** Peering must be declared in **both** VPCs' `.config.yaml` files (`hub` $\\rightarrow$ `prod` and `prod` $\\rightarrow$ `hub`).\n- **Custom Route Exchange:** `factory-peering.tf` defaults `routes_config.export` and `routes_config.import` to `true` so spokes automatically import on-premises prefixes learned by `hub` over VPN/Interconnect.\n- **Non-Transitivity Traps:**\n  1. Workloads in `dev` cannot reach `prod` over `hub` (and if they later need to exchange anonymized data or run shared agents across both environments, peering cannot route it without an intermediate proxy/NVA or migrating to NCC).\n  2. On-premises networks terminating in `hub` cannot reach Private Services Access (`psa_configs` / `servicenetworking`) peered to `dev` or `prod`.\n  3. Cloud DNS outbound forwarding packets (`35.199.192.0/19`) cannot traverse a VPC peering link to reach a VPN/Interconnect in another VPC—always host the forwarding zone in the VPC that terminates the hybrid link (and use DNS peering from spokes).\n\n---\n\n## 3. 3rd-Party NVA Routing & Failover Mechanics (`nva-ncc` vs. `nva`)\n\n### 3.1 Multi-Region NVA via NCC Router Appliance (`hub-and-spokes-nva-ncc`)\nWhenever 3rd-party stateful NVA VMs are used in a **multi-regional architecture** (or alongside PSA managed services), recommend `hub-and-spokes-nva-ncc`:\n\n```text\n  [On-Prem VPN/VLAN] ──> ( routing VPC ) <──BGP── [ NVA nic0 ]\n                              │                       │ (Stateful Inspection)\n                        (routing MESH hub)            ▼\n                                                [ NVA nic1 ] ──BGP──> ( transit VPC )\n                                                                            │\n                                                                 (transit STAR hub: center)\n                                                                            │\n                                                                 (transit STAR hub: edge)\n                                                                      ├── ( dev VPC )\n                                                                      └── ( prod VPC )\n```\n\n1. **Dual-Hub Architecture:**\n   - **`routing` Hub (`MESH`):** Connects hybrid spokes (HA VPN / VLAN attachments in the `routing` VPC) and `nic0` of each regional NVA as a Router Appliance spoke.\n   - **`transit` Hub (`STAR`):** Connects `nic1` of each regional NVA to the `center` group (`$ncc_groups:transit/center`) and workload spoke VPCs (`dev`, `prod`) to the `edge` group (`$ncc_groups:transit/edge`).\n   - *Critical Rule:* **Never host workloads in the `transit` VPC.** The `transit` VPC is the routing VPC for the `transit` RA spokes and learns spoke subnet routes directly from the hub; a VM inside `transit` would send traffic to a spoke via the NVA, while return traffic from the spoke would bypass the NVA and go directly to the `transit` subnet (asymmetric drop).\n2. **`routes` (Data Plane) vs. `advertise` (Control Plane) in `nvas/*.yaml`:**\n   - `routes` installs static Linux kernel routes inside the NVA guest OS via that NIC's subnet gateway so the OS knows which interface forwards to a prefix.\n   - `advertise` originates prefixes in FRR via BGP `network` statements toward Cloud Router peers (used on `nic1` in `transit` so edge spokes—which do not exchange subnet routes with each other in a `STAR` hub—learn routes to other spokes through the NVA).\n   - *Invariant:* Because FRR prefers its own locally originated `network` statement (`weight 32768`) over eBGP routes learned from Cloud Router (`weight 0`), **every prefix listed in `advertise` must be covered by a kernel route in `routes`** (or the default route on a `masquerade` NIC), otherwise the NVA kernel will lack a forwarding entry and blackhole the traffic.\n3. **Active-Passive (No Session Sync) vs. Active-Active ECMP:**\n   - **Active-Passive + Regional Affinity (Default in `hub-and-spokes-nva-ncc`):** Configure explicit per-instance BGP peers on the Cloud Routers using `$nva_addresses:<nva>-<zone>-<index>/<vpc>` and `$nva_instances:<nva>-<zone>-<index>`, attaching distinct `IMPORT` route policies (`med.set(100)` for primary zone `b`, `med.set(150)` for standby zone `c`, and `med.set(10100)` / `10150` for remote-region prefixes). This guarantees symmetric forward and return paths through the same regional primary NVA without requiring cross-instance state synchronization, while failing over automatically within the region or across regions if an appliance or region fails.\n   - **Active-Active ECMP:** Configure a fan-out peer (`nva: <nva-key>`) on the Cloud Router interfaces sharing a single import policy. Both zonal instances advertise at equal MED; use only when the NVA performs stateless inspection or runs active-active state synchronization (e.g., FGSP) and SNAT is excluded for private ranges (`masquerade_exclude`).\n4. **Why `NCC-RA` Wins for Managed Services (`PSA`):**\n   - Managed service producer networks (`servicenetworking` for Cloud SQL, AlloyDB, Memorystore, NetApp) do not have GCE network tags and cannot use network-tagged static routes. Because `NCC-RA` injects dynamic BGP routes into the VPC route table with regional MED priorities, PSA-peered services automatically use the local region's healthy NVA and fail over to the secondary region dynamically.\n\n### 3.2 Single-Region ILB Sandwich (`hub-and-spokes-nva`) & Hybrid `NCC-RA` + `ILB` Chaining\n- **When to use `ilb_config`:** Single-region NVA deployments (`hub-and-spokes-nva`, where `dmz`, `hub`, `dev`, and `prod` are all attached directly to the NVA's 4 NICs without VPC Peering), or when attaching a 3rd NIC (`nic2`) of an `NCC-RA` NVA to a dedicated DMZ or cross-environment bridge VPC using Internal Passthrough NLB next-hop static routes (`$addresses:<vpc>/<nva-name>`).\n- **Mandatory Conntrack & Health Check Guardrails:**\n  - The stage's `factory-nva.tf` configures a single shared health check and defaults `ilb_config.connection_tracking` to `track_per_session: false` (`PER_CONNECTION` 5-tuple tracking) and `persist_conn_on_unhealthy: NEVER_PERSIST` (with `session_affinity: NONE` and 600s idle timeout). Never override `ilb_config.connection_tracking` with `track_per_session: true` (`PER_SESSION` 2-tuple hashing causes asymmetric forward/return selection across NVA instances during health flaps).\n- **Intra-VPC Hairpinning with Policy-Based Routing (PBR):**\n  - If traffic *within* a single VPC (or entering a VPC from a specific source CIDR) must be forced through an NVA ILB, configure `policy_based_routes` in `vpcs/<vpc>/.config.yaml` pointing `next_hop_ilb_ip` to `$addresses:<vpc>/<nva-name>`.\n  - *PBR Guardrail:* Always include a higher-priority (lower numeric `priority` value) rule with `use_default_routing: true` matching Private Service Connect / Google API ranges first so wildcard PBR (`0.0.0.0/0`) does not hijack local PSC/PGA traffic into the NVA.\n\n---\n\n## 4. Firewall Policies & Cloud NGFW Enterprise\n\n1. **Hierarchical & Network Firewall Policies (`firewall-policies/*.yaml`):**\n   - Both Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>` or `organizations/<id>`) and Network Firewall Policies (`parent_id: $project_ids:<key>` with `region: global` or `$locations:<key>`) are defined in `firewall-policies/*.yaml` with an `attachments:` map inside the policy YAML file.\n   - Rules in `modules/net-firewall-policy` default to `action: allow` (ingress) or `action: deny` (egress) if `action` is omitted; always set `action: goto_next` explicitly on rules that should delegate evaluation to lower-level policies or VPC rules.\n   - Supports symbolic CIDR sets (`$cidr_ranges_sets:healthchecks`, `$cidr_ranges_sets:rfc1918`) defined in `defaults.yaml`.\n2. **VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n   - **Rule Key Uniqueness per Project:** GCE firewall rule names are project-scoped. If two VPCs share the same `project_id` (for example, `routing` and `transit` both living in `$project_ids:net-core-0`), their firewall rule keys across `vpcs/routing/firewall-rules/` and `vpcs/transit/firewall-rules/` **must be distinct** (e.g., `allow-routing-ssh` vs. `allow-transit-ssh`) to avoid HTTP 409 conflicts.\n   - **ICMP for PMTUD on VPN VPCs:** Any VPC terminating or transiting HA VPN tunnels must allow `icmp` ingress (especially `ICMP Type 3 Code 4` Fragmentation Needed) so Path MTU Discovery adjusts TCP MSS for in-flight connections across the `1406`/`1460`-byte IPsec tunnel MTU.\n3. **Cloud NGFW Enterprise Integration (L7 IPS / TLS Inspection):**\n   - In `firewall-policies/*.yaml`, rules can set `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>` (where `var.security_profile_groups` is passed from a `2-networking-ngfw` or security stage).\n\n---\n\n## 5. Cloud DNS & Name Resolution Mechanics\n\n1. **Private & Hybrid DNS Topologies (`dns/zones/`):**\n   There are three main patterns for structuring private and forwarding zones across hub and spoke VPCs:\n   - **Pattern A — Per-Environment Zones Bound to Hub + Root Peering (Recommended Default, used in the FAST reference datasets):**\n     1. Each environment manages its own private zone in its own spoke project (e.g., `dns/zones/net-dev-0/pvt-dev.yaml` with `project_id: $project_ids:net-dev-0`) and uses **cross-project VPC binding** (`private.client_networks: [$networks:hub, $networks:dev]`) to bind the zone to both the spoke VPC and the centralized hub VPC (`hub` or `routing`).\n     2. The centralized hub VPC—which **must** be the VPC where hybrid VPN/Interconnect connectivity terminates so Cloud DNS forwarding packets (`35.199.192.0/19`) have a direct path to on-premises resolvers—hosts the on-premises forwarding zone (`forwarding.client_networks: [$networks:hub]`) and top-level shared private zones.\n     3. All spoke VPCs attach to a root peering zone (`domain: .`, `peering.peer_network: $networks:hub`, `client_networks: [$networks:dev, $networks:prod]`)—or, alternatively, bind the forwarding zone directly across VPCs when routing allows. Root peering to the centralized VPC is **preferred** because spokes automatically resolve on-premises domains and all environment zones bound to the hub.\n   - **Pattern B — Fully Centralized Zones + Root Peering:**\n     - All private zones and on-premises forwarding zones are defined centrally in `net-core-0` and bound to the centralized hub VPC (`$networks:hub` or `$networks:routing`), while every spoke VPC peers root (`domain: .`) to the centralized VPC.\n   - **Pattern C — Cross-Project Bindings Only (`client_networks`):**\n     - Private zones (defined either centrally in `net-core-0` or per spoke project) are bound directly to all consumer VPCs via `private.client_networks: [$networks:hub, $networks:dev, $networks:prod]` (requires cross-project IAM permissions, which Stage 2 manages when all projects are in the dataset).\n2. **Cloud DNS Response Policies (`dns/response-policies/*.yaml`):**\n   - Used to steer Google APIs (`*.googleapis.com.`, `*.gcr.io.`, `*.pkg.dev.`) to `private.googleapis.com.` (`199.36.153.8/30`), `restricted.googleapis.com.` (`199.36.153.4/30`), or a custom PSC Google API endpoint IP.\n   - **Regional Endpoint (`*.rep.googleapis.com.`) Bypass:** Cloud DNS Response Policies match by **most-specific DNS suffix** (there is no `priority` attribute in `dns-response-policy-rules.schema.json`). When workloads use GCP Regional Endpoints (`<service>.<region>.rep.googleapis.com.`), include a rule for `dns_name: \"*.rep.googleapis.com.\"` with `behavior: bypassResponsePolicy`—because `*.rep.googleapis.com.` is more specific than `*.googleapis.com.`, it automatically bypasses the wildcard CNAME override so regional TLS certificate validation succeeds.\n   - **PSC Interfaces (`PSC-I`) & Root / `googleapis.com.` Peering:** If a spoke VPC uses PSC Interfaces (`network_attachments` for Vertex AI Agent Engine with VPC-scoped DNS), note that DNS Peering takes precedence over local private zones; avoid peering `googleapis.com.` (or use Pattern C cross-project bindings + RPZ on that spoke) so local PSC-I resolution is not overridden.\n","skills/fast/networking/references/3-stage-boundaries-and-plumbing.md":"# Phase 2 Reference: Stage 2 Boundaries & Downstream Plumbing Contract\n\n`fast/stages/2-networking` is a **foundational network plumbing stage**. It creates the projects, VPCs, subnets, IP reservations, routers, interconnects/VPNs, NCC hubs/spokes, base NVAs, DNS zones, and firewall policies that downstream stages consume.\n\nWhen a user states a requirement for a security capability, load balancer, container platform, or managed database, **never** invent unsupported YAML keys inside `2-networking` and **never** tell the user FAST cannot support it. Instead:\n1. Configure the **Stage 2 network plumbing** required by that service in the YAML dataset.\n2. Explain what is delegated to the **follow-up stage** (`1-vpcsc`, `2-networking-ngfw` / security stage, `2-project-factory`, or workload stages).\n3. Document every delegated item in the generated dataset's `README.md` under a **Downstream Stage Handoffs** section.\n\n---\n\n## 1. Stage Boundary Principle & Representative Plumbing Matrix\n\n**General Boundary Principle:** Any GCP service or security capability whose data-plane or control-plane depends on shared VPC primitives (subnets, secondary ranges, special-purpose subnets, IP reservations, PSA peerings, PSC network attachments, routes, DNS zones/RPZ, or firewall policy rules) has those **foundational network primitives provisioned in `2-networking`** (per `fast/stages/2-networking/schemas/*.schema.json`), while the **service, endpoint, or workload resource itself is provisioned in a follow-up stage**.\n\nThe table below shows representative examples of how common capabilities split across `2-networking` and downstream stages:\n\n| Requested Capability | What `2-networking` Provisions (Stage 2 YAML Plumbing) | What is Delegated to Other Stages (Out of Scope for `2-networking`) |\n| :--- | :--- | :--- |\n| **Cloud NGFW Enterprise** *(L7 IPS, Threat Prevention, TLS Inspection)* | • Hierarchical or network firewall policy rules in `firewall-policies/*.yaml` setting `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>`.<br/>• Context mapping via `var.security_profile_groups` in `.tfvars`. | • `google_network_security_firewall_endpoint`, `firewall_endpoint_association`, and `security_profile_group` resources (provisioned in a dedicated **`2-networking-ngfw` / security stage**). |\n| **Application Load Balancers** *(Internal L7 ALB, Regional External ALB, Service Extensions)* | • Regional proxy-only subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `proxy_only: true`, `/23` recommended, `/26` GCP minimum per region/VPC).<br/>• Static VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal`, `external`, or `global`).<br/>• Firewall rules allowing health-check probers (`$cidr_ranges_sets:healthchecks`: `35.191.0.0/16`, `130.211.0.0/22`) and proxy subnet CIDRs. | • Load balancer forwarding rules, target HTTP(S) proxies, URL maps, backend services, NEGs, and Cloud Armor policies (`modules/net-lb-app-*`, deployed in **workload stages**). |\n| **Internal Passthrough NLBs** *(Application-Tier L4 ILBs)* | • Workload subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`).<br/>• Static internal VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal:`).<br/>• Health-check allow rules (`35.191.0.0/16`, `130.211.0.0/22`). | • Application-tier `modules/net-lb-int` resources (deployed in **workload stages**). *(Note: NVA transit ILBs ARE managed directly by `2-networking` via `nvas/*.yaml` `ilb_config`).* |\n| **GKE Clusters** *(Standard & Autopilot)* | • Shared VPC host enablement (`shared_vpc_host_config.enabled: true`) and `container.googleapis.com` API in `projects/net-*.yaml`.<br/>• Primary node subnets with `secondary_ip_ranges` (`pods` and `services`) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• Optional firewall rules for private control-plane webhooks/master CIDRs. | • Service project creation and Shared VPC subnet-level IAM attachment (`roles/compute.networkUser`, `roles/container.hostServiceAgentUser` in **`2-project-factory`**).<br/>• GKE cluster & nodepool provisioning (`modules/gke-*` in **workload stages**). |\n| **Private Service Connect (PSC) — Producer** *(Publishing Services Across VPCs/Orgs)* | • Dedicated PSC NAT subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `psc: true`, `/24` recommended per service attachment).<br/>• Regional `proxy_only: true` subnet if the published producer LB is an Internal Regional ALB. | • Producer load balancer and `google_compute_service_attachment` (`modules/net-lb-*` in **producer workload stages**). |\n| **Private Service Connect (PSC) — Consumer** *(Consuming SaaS, Managed DBs, or Google APIs)* | • Consumer PSC endpoint IPs in `vpcs/<vpc>/addresses.yaml` under `psc:` (`subnet_self_link` + `service_attachment: { psc_service_attachment_link: ... }` for service attachments, or `global: true` + `target: all-apis\\|vpc-sc` for Google API bundles).<br/>• Private Cloud DNS zones/records in `dns/zones/` or RPZ rules in `dns/response-policies/`.<br/>• `export_psc: true` on `ncc-hubs/*.yaml` to propagate consumer endpoints across NCC spokes. | • Producer-side project allowlisting (`consumer_accept_lists`) if the producer is external. |\n| **Private Services Access (PSA)** *(Cloud SQL, AlloyDB, Memorystore, NetApp Volumes)* | • `servicenetworking.googleapis.com` API in `projects/net-*.yaml`.<br/>• `psa_configs` in `vpcs/<vpc>/.config.yaml` (allocating `/24`–`/16` internal CIDR ranges, `export_routes: true`, `import_routes: true`).<br/>• Cloud Router `custom_advertise.ip_ranges` if on-premises networks need to reach the PSA ranges.<br/>• Note: If multiple NCC spokes need transitive access to a PSA peering (`servicenetworking-googleapis-com` or `sn-netapp-prod`), document attaching an **NCC Producer VPC Spoke** (`linked_producer_vpc_network`) or using PSC endpoints instead. | • Managed database or storage instances (`modules/cloudsql-*`, `alloydb`, `netapp-volumes` in **workload stages**). |\n| **Serverless & Vertex AI Runtimes** *(Cloud Run Direct VPC Egress, Vertex AI Agent Engine PSC-I)* | • Dedicated subnets (`>= /26`, `/24` recommended to account for `2x` rollout IP reservation) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• `network_attachments` in `vpcs/<vpc>/.config.yaml` for PSC Interfaces (`PSC-I`, referencing `<resolved-region>/<subnet-name>` and `automatic_connection`).<br/>• Cloud DNS RPZ bypass (`*.rep.googleapis.com.` `bypassResponsePolicy`) and ensuring `googleapis.com.` is **not** in `dns/zones/` as a DNS Peering zone on VPCs using `PSC-I`. | • Service project attachment in **`2-project-factory`**.<br/>• Cloud Run services, Vertex AI endpoints, and Agent Engine deployments in **workload stages**. |\n| **VPC Service Controls (VPC-SC)** *(Data Exfiltration Perimeters)* | • Plumbing for `restricted.googleapis.com` (`199.36.153.4/30`) routes and DNS response policies (`dns/response-policies/*.yaml`).<br/>• Optional `$vpc_sc_perimeters:` mapping on networking host projects (`var.perimeters`). | • Access Context Manager policies, access levels, and regular/bridge service perimeters (managed in **`1-vpcsc`**). |\n\n---\n\n## 2. Example YAML Plumbing Snippets for `2-networking`\n\nThe snippets below are **illustrative examples** showing how Stage 2 plumbing is expressed against the `fast/stages/2-networking/schemas/*.schema.json` schemas. Adapt names, keys, regions, and CIDRs to the user's design, and consult the JSON schemas in `fast/stages/2-networking/schemas/` for the full set of supported attributes. *(Note: when grouping subnets in regional folders under `vpcs/<vpc>/subnets/<region>/`, ensure every `.yaml` file within a VPC has a **unique filename stem**, e.g. `prod-proxy-ew1.yaml` and `prod-proxy-ew8.yaml`, because `modules/net-vpc` keys raw subnet files by basename).*\n\n### 2.1 Regional ALB Proxy-Only & PSC Producer Subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-proxy-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-proxy-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.128.0/23\ndescription: Regional Managed Proxy subnet for Internal and Regional External ALBs\nproxy_only: true\nactive: true\n```\n\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-psc-nat-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-psc-nat-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.130.0/24\ndescription: PSC NAT subnet for publishing Service Attachments\npsc: true\n```\n\n### 2.2 GKE Node Subnet with Pod & Service Secondaries (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-gke-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-gke-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.0.0/22\ndescription: Primary subnet for GKE nodes with secondary Pod and Service ranges\nsecondary_ip_ranges:\n  pods:\n    ip_cidr_range: 10.104.0.0/17\n  services:\n    ip_cidr_range: 10.108.0.0/21\n```\n\n### 2.3 PSA Ranges & PSC Interfaces (`network_attachments`) in `vpcs/<vpc>/.config.yaml`\n```yaml\n# Inside vpcs/prod/.config.yaml\npsa_configs:\n  - ranges:\n      cloudsql-psa: 10.110.0.0/20\n    export_routes: true\n    import_routes: true\n\nnetwork_attachments:\n  agent-engine-ew1:\n    # Must be '<resolved-region>/<subnet-name>' matching a subnet in this VPC:\n    subnet: europe-west1/prod-psc-int-ew1\n    automatic_connection: false\n    # Literal GCP project IDs (producer_accept_lists does not interpolate $project_ids):\n    producer_accept_lists:\n      - my-ai-workload-prod-0\n```\n\n### 2.4 Reserved VIPs & PSC Consumer Endpoints (`vpcs/<vpc>/addresses.yaml`)\n```yaml\n# vpcs/prod/addresses.yaml\n# yaml-language-server: $schema=../../../schemas/addresses.schema.json\n\ninternal:\n  # Referenced in routes/PBR as $addresses:prod/app-ilb-ew1\n  app-ilb-ew1:\n    region: $locations:ew1\n    subnetwork: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.10\n\npsc:\n  alloydb-psc-ew1:\n    region: $locations:ew1\n    subnet_self_link: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.50\n    service_attachment:\n      psc_service_attachment_link: projects/producer-prj/regions/europe-west1/serviceAttachments/alloydb-sa\n```\n\n### 2.5 Cloud NGFW Enterprise Rule in `firewall-policies/*.yaml`\n```yaml\n# Inside firewall-policies/networking-policy.yaml\ningress_rules:\n  inspect-east-west:\n    priority: 1000\n    action: apply_security_profile_group\n    security_profile_group: $security_profile_groups:ips-profile-group\n    description: Delegate L7 IPS inspection to Cloud NGFW Enterprise\n    match:\n      source_ranges:\n        - $cidr_ranges_sets:rfc1918\n      destination_ranges:\n        - $cidr_ranges_sets:rfc1918\n      layer4_configs:\n        - protocol: all\n```\n\n---\n\n## 3. Handoff Outputs Contract for Downstream Stages\n\nWhen `2-networking` runs `terraform apply`, it emits stage outputs (`host_project_ids`, `vpc_self_links`, `subnet_self_links`, `subnet_proxy_only_self_links`, `subnet_psc_self_links`, and `2-networking.auto.tfvars.json`) consumed by `2-project-factory` and workload stages.\n\nAlways include a **Downstream Stage Handoffs** table in the generated dataset's `README.md` listing:\n1. Which **Shared VPC Host Projects** (`$project_ids:<key>`) and **Subnets** (`<vpc>/<region>/<subnet>`) downstream service projects should attach to in `2-project-factory`.\n2. Which **`proxy_only`**, **`psc`**, **`psa_configs`**, or **`network_attachments`** resources were pre-created in `2-networking` and which workload stage / CFF module (`gke-cluster-*`, `net-lb-app-*`, `cloudsql-*`) will consume them.\n3. Any external context variables (`var.security_profile_groups`, `var.folder_ids`, `var.kms_keys`, `var.perimeters`) that must be passed into `2-networking.auto.tfvars` when running `terraform apply`.\n","skills/fast/networking/references/4-dataset-schema-and-tokens.md":"# Phase 2 & 3 Reference: Dataset Directory Layout, Schemas & `$token` Rules\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)** and **Phase 3 (Dataset Generation & Validation)**. Consult the live JSON schemas in `fast/stages/2-networking/schemas/*.schema.json` alongside this guide when generating or modifying YAML files.\n\n---\n\n## 1. Canonical Dataset Directory Hierarchy & Schema Mapping\n\nEvery YAML file in a `2-networking` dataset must sit at one of the canonical factory paths below and begin with a `# yaml-language-server: $schema=...` header pointing to the corresponding schema in `fast/stages/2-networking/schemas/`:\n\n| Dataset-Relative Path | JSON Schema (`fast/stages/2-networking/schemas/`) | Factory & Purpose |\n| :--- | :--- | :--- |\n| `defaults.yaml` | `defaults.schema.json` | Stage defaults (`vpcs.defaults`, `projects.defaults`), `output_files`, and `context` maps (`locations`, `cidr_ranges`, `cidr_ranges_sets`, `email_addresses`, `iam_principals`, etc.). |\n| `folders/**/.config.yaml` | `folder.schema.json` | Optional folder hierarchy definitions (up to 4 levels) under the networking branch. |\n| `projects/**/*.yaml` or `folders/**/*.yaml` *(except `.config.yaml`)* | `project.schema.json` | Networking host/transit project definitions (`name`, `parent`, `services`, `shared_vpc_host_config`, `project_reuse`). Projects placed under `folders/<dir>/<proj>.yaml` automatically inherit `parent: $folder_ids:<dir>`. |\n| `ncc-hubs/*.yaml` | `ncc-hub.schema.json` | NCC Hub (`name`, `project_id`, `preset_topology: MESH\\|STAR`, `export_psc`, `groups`). |\n| `nvas/*.yaml` | `nva.schema.json` | Multi-NIC NVA clusters (`auto_instance_config.nics`, `instance_groups`, `ilb_config`, `ncc_ra_config`, `bgp_config`). |\n| `firewall-policies/**/*.yaml` | `firewall-policy.schema.json` | Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>`) and Global/Regional Network Firewall Policies (`parent_id: $project_ids:<key>`, `region: global` or `$locations:<key>`, `attachments: { <vpc>: $networks:<vpc> }`). |\n| `dns/zones/**/*.yaml` | `dns.schema.json` | Cloud DNS zones (`private`, `forwarding`, `peering`, `public`, `recordsets`, `delegations`). |\n| `dns/response-policies/**/*.yaml` | `dns-response-policy-rules.schema.json` | Cloud DNS Response Policy zones and rules (`local_data`, `behavior: bypassResponsePolicy`). |\n| `vpcs/<vpc-key>/.config.yaml` | `vpc.schema.json` | VPC configuration (`name`, `project_id`, `mtu`, `routing_mode`, `nat_config`, `ncc_config`, `peering_config`, `psa_configs`, `network_attachments`, `routers`, `routes`, `policy_based_routes`, `dns_policy`, `factories_config`). |\n| `vpcs/<vpc-key>/addresses.yaml` | `addresses.schema.json` | Reserved addresses in `<vpc-key>` (`internal`, `external`, `global`, `psc`, `psa`). |\n| `vpcs/<vpc-key>/firewall-rules/**/*.yaml` | `firewall-rules.schema.json` | VPC-level `ingress` and `egress` firewall rules. |\n| `vpcs/<vpc-key>/subnets/**/*.yaml` | `subnet.schema.json` | Subnet definitions (`name`, `region`, `ip_cidr_range`, `secondary_ip_ranges`, `proxy_only`, `psc`, `private_nat`, `iam`). |\n| `vpcs/<vpc-key>/vpns/**/*.yaml` | `vpn.schema.json` | HA VPN gateways (`name`, `region`, `peer_gateways`, `router_config`, `tunnels`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/vlan-attachments/**/*.yaml` | `vlan-attachments.schema.json` | Dedicated/Partner Interconnect VLAN attachments (`name`, `region`, `peer_asn`, `router_config`, `dedicated_config` / `partner_config`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/attachment-groups/**/*.yaml` | `attachment-groups.schema.json` | Interconnect attachment groups for SLA redundancy. |\n\n---\n\n## 2. Symbolic Context (`$token`) Namespace & Key Derivation Rules\n\nFAST `2-networking` resolves `$namespace:key` tokens across factories. Never hardcode GCP resource self-links when a context token exists.\n\n### 2.1 Resources Created Inside the Dataset\nPay close attention to whether a token key comes from the **directory/file stem** or the **`name:` attribute** inside the YAML file:\n\n| Token Syntax | Key Derivation Rule | Resolved Value & Where Used |\n| :--- | :--- | :--- |\n| `$project_ids:<project-key>` | **File stem** of `projects/**/<project-key>.yaml` or `folders/**/<project-key>.yaml` (e.g., `projects/net-core-0.yaml` $\\rightarrow$ `$project_ids:net-core-0`). | Project ID. Used in `project_id:` across VPCs, DNS zones, NCC hubs, NVAs, and `ncc-hubs/*.yaml` `auto_accept`. |\n| `$folder_ids:<folder-key>` | **Relative directory path** under `folders/` (or passed from Stage 0 `var.folder_ids`, e.g., `$folder_ids:networking`). | Folder ID (`folders/123456`). Used in `projects/*.yaml` `parent:` and `firewall-policies/*.yaml` `parent_id:` / `attachments:`. |\n| `$networks:<vpc-key>` | **Directory path** under `vpcs/<vpc-key>/.config.yaml` (e.g., `vpcs/prod/.config.yaml` with `name: prod-0` is referenced as **`$networks:prod`**, NOT `prod-0`). | VPC self-link (or name in VPNs). Used in `peering_config`, `dns/zones/*.yaml` `client_networks` & `peer_network`, `firewall-policies/*.yaml` `attachments`, and `nvas/*.yaml` `nics[].network`, `ilb_config.forwarding_rules[].network`, & `ncc_ra_config.spokes[].network`. |\n| `$subnets:<vpc-key>/<region>/<subnet-name>` | `<vpc-key>` (directory under `vpcs/`) + **resolved region name** (e.g. `europe-west1`, NOT the `$locations:` alias) + **`name:` attribute** inside the subnet YAML. *(Resolves regular and `private_nat` subnets only, not `proxy_only` or `psc` subnets).* | Subnet self-link. Used in `addresses.yaml` (`subnetwork` / `subnet_self_link`), `nvas/*.yaml` (`nics[].subnet`), Cloud NAT `subnetworks`, and Cloud Router `interfaces[].subnet`. |\n| `$ncc_hubs:<hub-name>` | Required **`name:` attribute** inside `ncc-hubs/<file>.yaml`. | NCC Hub ID. Used in `vpcs/<vpc>/.config.yaml` `ncc_config.hub`, `vpns`/`vlan-attachments` `ncc_spoke_config.hub`, and `nvas/*.yaml` `ncc_ra_config.spokes[].hub`. |\n| `$ncc_groups:<hub-name>/<group-key>` | `<hub-name>` (`name:` in `ncc-hubs/*.yaml`) + map key under `groups:` (e.g., `$ncc_groups:hub/default` or `$ncc_groups:transit/center`). | NCC Group ID. Used in `ncc_config.group`, `ncc_spoke_config.group`, and `ncc_ra_config.spokes[].group`. |\n| `$routers:<vpc-key>/<router-key>` | `<vpc-key>` (directory under `vpcs/`) + map key under `routers:` in `vpcs/<vpc-key>/.config.yaml`. | Cloud Router name. Used in `vpns/*.yaml` and `vlan-attachments/*.yaml` under `router_config.name` (with `create: false`). |\n| `$vpn_gateways:<vpc-key>/<vpn-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vpns/<file>.yaml` (e.g., `vpcs/hub/vpns/onprem.yaml` with `name: to-onprem` $\\rightarrow$ `$vpn_gateways:hub/to-onprem`). | HA VPN Gateway ID. Used in `peer_gateways.<key>.gcp` for GCP-to-GCP HA VPNs. |\n| `$vlan_attachments:<vpc-key>/<att-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vlan-attachments/<file>.yaml`. | VLAN Attachment ID. Used in `vpcs/<vpc-key>/attachment-groups/*.yaml` `attachments:`. |\n| `$attachment_groups:<vpc-key>/<grp-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/attachment-groups/<file>.yaml`. | Interconnect Attachment Group ID. Used in `vpcs/<vpc-key>/vlan-attachments/*.yaml` `attachment_group:`. |\n| `$addresses:<vpc-key>/<addr-key>` | `<vpc-key>` + map key under `internal:` in `vpcs/<vpc-key>/addresses.yaml` (e.g., `internal: { app-ilb: ... }` in `vpcs/prod/addresses.yaml` $\\rightarrow$ **`$addresses:prod/app-ilb`**, with NO `/internal/` segment). | Reserved internal IP address. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop`) and `policy_based_routes` (`next_hop_ilb_ip`). |\n| `$addresses:<vpc-key>/<nva-name>` | `<vpc-key>` (from `ilb_config.forwarding_rules[].network`) + **`name:` attribute** of the NVA in `nvas/<file>.yaml`. | NVA Internal Passthrough NLB VIP in `<vpc-key>`. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop_type: ilb`, `next_hop:`) and `policy_based_routes` (`next_hop_ilb_ip:`). |\n| `$nva_instances:<nva-name>-<zone>-<index>` | `<nva-name>` (`name:` in `nvas/*.yaml`) + `<zone>` key in `instance_groups:` + `0`-based instance index (for `auto_create_instances > 0`, e.g., `$nva_instances:primary-b-0`). | NVA VM instance self-link. Used in explicit Cloud Router `peers[].router_appliance_instance` (`vpcs/<vpc>/.config.yaml`) and `ncc_ra_config.spokes[].instances[].vm`. |\n| `$nva_addresses:<nva-name>-<zone>-<index>/<vpc-key>` | Instance key (`<nva-name>-<zone>-<index>`) + `<vpc-key>` of the attached NIC (e.g., `$nva_addresses:primary-b-0/routing`). | NVA VM NIC internal IP in `<vpc-key>`. Used in explicit Cloud Router `peers[].address` and `ncc_ra_config.spokes[].instances[].ip`. |\n\n### 2.2 Stage Context & External Variables (`defaults.yaml` & `var.context`)\n- **`$locations:<key>`:** Defined under `context.locations` in `defaults.yaml` (e.g., `ew1: europe-west1`, `ew8: europe-west8`). Supported across subnets, routers, Cloud NAT, NVAs, VPNs, VLAN attachments, firewall policies, addresses, routes, and `policy_based_routes`.\n- **`$cidr_ranges:<key>` & `$cidr_ranges_sets:<key>`:** Defined under `context.cidr_ranges` and `context.cidr_ranges_sets` in `defaults.yaml`.\n  - `$cidr_ranges_sets:<key>` is resolved in **both** `firewall-policies/*.yaml` and VPC `vpcs/<vpc>/firewall-rules/*.yaml`.\n  - `$cidr_ranges:<key>` is resolved in `vpcs/<vpc>/.config.yaml` `routes` and `policy_based_routes`, as well as subnet configs and `firewall-rules/*.yaml`.\n- **`$custom_roles:<key>`, `$iam_principals:<key>`, `$email_addresses:<key>`, `$kms_keys:<key>`, `$tag_keys:<key>`, `$tag_values:<key>`, `$vpc_sc_perimeters:<key>`:** Defined in `defaults.yaml` `context` (except `folder_ids`, which comes from `var.folder_ids` or `var.context.folder_ids`) or passed from root stage variables (`var.custom_roles`, `var.iam_principals`, `var.service_accounts`, `var.kms_keys`, `var.tag_keys`, `var.tag_values`, `var.perimeters`).\n- **`$security_profile_groups:<key>`:** Passed via `var.security_profile_groups` (or `defaults.yaml` `context.security_profile_groups`). Used in `firewall-policies/*.yaml` rules with `action: apply_security_profile_group`.\n\n### 2.3 Common Schema & Factory Gotchas\n- **Unique Subnet Filename Stems per VPC (`vpcs/<vpc>/subnets/`):** `modules/net-vpc` keys raw subnet YAML files by `basename(file)` (stripping any `<region>/` subdirectory). Never create two subnet files with the same filename in different regional folders of the same VPC (e.g., `subnets/ew1/default.yaml` and `subnets/ew8/default.yaml` will collide); always include the region or tier in the filename (`subnets/ew1/prod-default-ew1.yaml`).\n- **Subnet `secondary_ip_ranges` (`subnet.schema.json`):** Each secondary range entry under `secondary_ip_ranges:` must be an **object** with `ip_cidr_range` (or `reserved_internal_range`), never a bare CIDR string:\n  ```yaml\n  secondary_ip_ranges:\n    pods:\n      ip_cidr_range: 10.104.0.0/17\n    services:\n      ip_cidr_range: 10.108.0.0/21\n  ```\n- **VPC `network_attachments` (`vpc.schema.json`):** Under `network_attachments.<key>`, `subnet` must be `<resolved-region>/<subnet-name>` (e.g., `europe-west1/prod-psc-int-ew1`), the connection toggle is `automatic_connection: true|false` (not `automatic_accept`), and `producer_accept_lists` / `producer_reject_lists` take literal project IDs (they do not interpolate `$project_ids:`).\n- **Disabling VLAN Attachments (`vpcs/<vpc>/.config.yaml`):** To disable loading `vlan-attachments/` for a VPC (such as in the reference datasets), set `factories_config: { vlan_attachments: \"none\" }` inside `vpcs/<vpc>/.config.yaml`—there is no `vlan_attachments` key under `var.factories_config.paths` in `.tfvars`.\n- **Firewall Policies (`firewall-policies/*.yaml`) vs. VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n  - `firewall-policies/*.yaml` (`firewall-policy.schema.json`) uses `ingress_rules` / `egress_rules`, `action: allow|deny|goto_next|apply_security_profile_group`, `match.source_ranges`, `match.destination_ranges`, and `match.layer4_configs: [{ protocol: tcp, ports: [\"22\"] }]`.\n  - `vpcs/<vpc>/firewall-rules/*.yaml` (`firewall-rules.schema.json`) uses `ingress` / `egress` maps where each rule defines top-level `source_ranges` / `destination_ranges`, `rules: [{ protocol: tcp, ports: [\"22\"] }]` (with `deny: false` for allow or `deny: true` for deny), and `enable_logging: { include_metadata: false }` (an object, not a boolean).\n\n---\n\n## 3. Generating `2-networking.auto.tfvars`\n\nAlways generate a companion `2-networking.auto.tfvars` file next to (or pointing to) the custom dataset directory:\n\n### 3.1 When Deployed on Top of FAST `0-org-setup`\n`0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` already supply `billing_account`, `organization`, `prefix`, `folder_ids`, `service_accounts`, and `storage_buckets`. The `.tfvars` file only needs `factories_config`:\n\n```hcl\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n### 3.2 When Deployed Standalone (Without `0-org-setup`)\nProvide the required FAST root variables (`billing_account`, `organization`, `prefix`) and any referenced `$folder_ids` or `$project_ids` (if reusing existing projects). In `defaults.yaml`, either omit `output_files.storage_bucket` (or use `output_files.local_path`) or pass `storage_buckets` in `.tfvars` so `apply` does not attempt to write to an unresolved `$storage_buckets:iac-0/iac-outputs` string:\n\n```hcl\nbilling_account = {\n  id = \"012345-6789AB-CDEF01\"\n}\n\norganization = {\n  domain      = \"example.com\"\n  id          = 123456789012\n  customer_id = \"C01234567\"\n}\n\nprefix = \"myorg\"\n\nfolder_ids = {\n  networking = \"folders/1234567890\"\n}\n\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n---\n\n## 4. Phase 3 Execution: Validation & `terraform plan`\n\nAfter writing the YAML files into the user's custom dataset directory:\n\n1. **Run the Bundled Validator Script (Mandatory Gate):**\n   ```bash\n   uv run skills/fast/networking/scripts/validate_dataset.py <path/to/custom-dataset>\n   ```\n   - This script validates:\n     1. Every YAML file against its JSON schema in `fast/stages/2-networking/schemas/`.\n     2. All `$token` references, strict CIDR syntax (no host bits set), intra-VPC and cross-VPC CIDR overlaps, unique subnet file basenames per VPC, reciprocal VPC peerings, unique firewall rule names per project, and NVA (`ilb_config` / `ncc_ra_config` / BGP / `routes` vs. `advertise`) guardrails.\n     3. Isolated `terraform init -backend=false`, `terraform validate`, and `terraform plan -refresh=false` in a temporary directory using synthetic FAST variables.\n   - Fix any reported errors or unintended warnings and re-run until `validate_dataset.py` exits with code `0`.\n\n2. **Write `<custom-dataset>/README.md`:**\n   Include a concise `README.md` in the custom dataset folder summarizing:\n   - Topology overview and regional IPAM & VPC summary table.\n   - **Downstream Stage Handoffs** (any requested ALBs, Cloud NGFW Enterprise security profile groups, GKE secondary ranges, PSC service attachments, or PSA databases that follow-up stages will deploy on top of this plumbing).\n","skills/fast/prerequisites/SKILL.md":"---\nname: fast-prerequisites\ndescription: Guides the user step-by-step through the prerequisites for the FAST 0-org-setup stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for 0-org-setup or bootstrap the FAST landing zone.\n---\n\n# FAST 0-org-setup Prerequisites Guide\n\n## Core Principles & Execution Rules\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):** \n> To ensure the human user always knows where they are in the workflow and can immediately spot if you skip steps or make false assumptions, **EVERY SINGLE response you output MUST start with this progress block**. Do not omit it, do not shorten it, and do not skip step sequences.\n>\n> Format to prepend to EVERY message:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (Step 1/2: Target Environment Selection - IN PROGRESS)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Not started)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n>\n> As steps are completed, update the bracketed lines to show completed steps or current active step, e.g.:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (2/2 steps completed)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Step 1/2: Admin Principal Definition - IN PROGRESS)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n\n> [!CRITICAL]\n> **Understanding Turn Boundaries:** You are running in a turn-based execution environment. \n> - You receive one user message, then you can think and run tools.\n> - Once you decide you need input from the user (e.g. to choose an environment or confirm a principal), you MUST output your question and **STOP execution immediately (do not call more tools)**. \n> - You **CANNOT** receive the user's answer in the same turn. \n> - You **MUST NOT** simulate, guess, or assume the user's response in your subsequent thinking blocks during the same turn. You must wait for the next turn to receive the actual input.\n>\n> **Interactive Questions (`ask_user` / `ask_question`):** Use the `ask_user` (or `ask_question`) tool whenever available to prompt the user, especially whenever multiple options or choices are presented.\n>\n> **Do NOT Skip Steps or Make Assumptions:** You MUST NOT skip any phases or steps, even if you think they are redundant or if you find information on the system (like active credentials) that suggests a step is already complete. You MUST execute every step sequentially, in order, and wait for explicit user input/confirmation at each step boundary.\n>\n> **MANDATORY START POINT (TURN 1):** You MUST explicitly ask the user to choose their target environment (Standard GCP or GCD) in your first turn. Do NOT check active credentials or run background commands to bypass this step. The environment selection is a fundamental gateway that dictates all downstream resources, variables, and configurations. Proceeding without explicitly getting this choice in Turn 1 is a critical failure.\n>\n> **Strictly One Question at a Time:** You MUST NOT bundle multiple questions or steps together in a single response. Ask exactly one question, wait for the user's answer, and only then proceed to the next question or action.\n>\n> **Sandbox Awareness:** You are running inside an isolated, sandboxed temporary workspace (e.g., `/tmp/gemini_harness_*`). Whenever creating local files, configuration directories (like `custom-fast-config` or `fast-config`), or checking defaults, you MUST do so strictly relative to your current workspace directory (CWD). NEVER try to directly read or write to `/home/ludomagno/` or other external folders, as your file tools are sandboxed and will fail with permission errors.\n>\n> **Step-by-Step Execution:** Never implement a single \"magical\" flow. Go through each step one at a time, explaining context and asking for confirmation.\n3. **Execution Choice:** Respect the user's execution preference (automatic via `run_command` vs. manual copy/paste) throughout the entire workflow unless the user explicitly instructs you to change it. This preference will be gathered during Phase 1.\n4. **File Modifications:** Always use `replace` or `write_file`. **Never** use opaque shell commands (like `sed`, `echo >>`, or `cat <<EOF >>`). Show proposed edits and ask for confirmation before applying them so the user can see what we're doing.\n5. **YAML Validation:** Validate generated YAML using `yamllint -c .yamllint --no-warnings <file>`. If the command is not available, ask the user if they prefer to a) install it, b) have you install it (`pip install yamllint`), or c) skip validation.\n6. **Resuming Mid-Flow:** If the user is resuming a previously interrupted session, ask them which Phase or Step they left off at, or assess the current state by reading previously generated files (e.g., `defaults.yaml`, `0-org-setup.auto.tfvars`). Read the corresponding reference file for the identified phase and resume execution directly from that point.\n\n## Workflow Map\n\nGuide the user through the following sequence strictly in order. **Before starting a phase, read its corresponding reference document to get the exact instructions, commands, and logic.**\n\n### Phase 1: Environment & Authentication\n*Description:* Determine the target environment (Standard GCP or GCD) and ensure the user is properly authenticated.\\\n*Reference: [Environment & Authentication](references/phase1-env-and-auth.md)*\n- **Step 1:** Environment Assessment & Initialization (Standard vs GCD)\n- **Step 2:** Authentication\n\n### Phase 2: Admin Principal & Baseline Info\n*Description:* Define the core administrative identity and gather essential baseline data like Organization and Billing IDs.\\\n*Reference: [Admin Principal & Baseline Info](references/phase2-admin-and-baseline.md)*\n- **Step 3:** Admin Principal Definition (Group vs Single User)\n- **Step 4:** Baseline Information Gathering (Org ID, Billing ID, Billing Access Scenarios)\n\n### Phase 3: Bootstrap Project & IAM\n*Description:* Assign the necessary organization-level IAM roles and set up a temporary project for API quota tracking.\\\n*Reference: [Bootstrap Project & IAM](references/phase3-bootstrap-and-iam.md)*\n- **Step 5:** IAM Role Assignments\n- **Step 6:** Bootstrap Project Setup (Creation and API enablement)\n\n### Phase 4: Configuration & Wrap-up\n*Description:* Generate the FAST dataset configuration, handle existing organization policies and essential contacts, and prepare for the final Terraform apply.\\\n*Reference: [Configuration & Wrap-up](references/phase4-config-and-wrapup.md)*\n- **Step 7:** Configuration Generation (Datasets, defaults.yaml, local paths)\n- **Step 8:** Organization Policy Import Check & Essential Contacts\n- **Step 9:** Wrap-up & Apply\n","skills/fast/prerequisites/TESTING.md":"# FAST 0-org-setup Prerequisites - Testing Workflows\n\nThis document outlines the various testing workflows and combinations for the `fast-prerequisites` skill to ensure all branches of the skill's logic are covered.\n\n## Core Variables & Decision Points\n\n1. **Environment:** Standard GCP vs. Google Cloud Dedicated (GCD)\n2. **GCD Universe (if applicable):** S3NS (France), Berlin (Germany), or Custom\n3. **Execution Mode:** Auto (Gemini CLI runs commands) vs. Manual (Output commands for user)\n4. **Authentication State:** Already authenticated vs. Needs authentication\n5. **Admin Principal Approach:** Group (Approach A) vs. Single User (Approach B)\n6. **Billing Account Access Level:** Billing Administrator vs. Billing User vs. No Access\n7. **Bootstrap Project:** Pre-existing (with or without API check) vs. New project created by user\n8. **Configuration / Dataset:** Standard GCP datasets vs. `classic-gcd` dataset\n9. **Org Policy Import Check & Essential Contacts:** Pre-existing policies found vs. No policies found; Essential Contacts configured (deploying user vs. custom email) vs. Essential Contacts policy set to allow all\n\n## Comprehensive Test Scenarios\n\n### Scenario 1: Standard GCP - The \"Simple User\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Manual\n* **Authentication:** Needs authentication (Standard auth flow)\n* **Admin Principal:** Single User (current authenticated user)\n* **Information Gathering:** Direct input of Org ID and Billing ID\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** New project (user creates, CLI outputs full API enable command)\n* **Configuration:** Default local path, standard dataset, additional context added\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n### Scenario 2: Standard GCP - The \"Developer\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Auto\n* **Authentication:** Already authenticated\n* **Admin Principal:** Group (`foo-owners@example.com`)\n* **Information Gathering:** Uses \"list\" to fetch Org ID and Billing ID\n* **Billing Access:** Billing User (requires disabling billing YAML)\n* **Bootstrap Project:** Pre-existing project (auto-check and enable missing APIs)\n* **Configuration:** Custom local path, standard dataset, no additional context\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n#### Notes\n\n### Scenario 3: GCD (S3NS) - Automated Setup\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** S3NS (France)\n* **Execution Mode:** Auto\n* **Authentication:** Needs authentication (WIF login flow)\n* **Admin Principal:** Group\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** Pre-existing project (skip API check, enable all)\n* **Configuration:** `classic-gcd` dataset (fixed), auto-mapped universe regions, providers.tf generated\n* **Org Policies:** No policies exist\n\n### Scenario 4: GCD (Custom) - Manual Setup with Restrictions\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** Custom (manually input all 5 variables)\n* **Execution Mode:** Manual\n* **Authentication:** Already authenticated\n* **Admin Principal:** Single User\n* **Billing Access:** No Access (requires warning and manual continuation)\n* **Bootstrap Project:** New project\n* **Configuration:** `classic-gcd` dataset, custom local path\n* **Org Policies:** Some policies exist\n","skills/fast/prerequisites/references/phase1-env-and-auth.md":"# Phase 1: Environment & Authentication\n\n### Step 1: Environment Assessment & Initialization\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your very first Turn 1 response prepends the progress block showing:\n>   - Phase 1: Environment & Authentication (Step 1/2: Target Environment Selection - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n>\n> **MANDATORY START POINT (TURN 1):** \n> - You MUST begin the entire workflow by asking the user to clarify their target environment (**Standard GCP** or **Google Cloud Dedicated (GCD)**). \n> - You **MUST NOT** run any `gcloud` commands, check active credentials, or proceed to Step 2 or any other Phase/Step in Turn 1. \n> - You **MUST** stop execution immediately after asking this question and wait for the user's response.\n> - Do NOT assume, pre-fill, or guess the environment based on local config files, active credentials, or background command success.\n>\n> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) in a subsequent turn and wait for their response.\n\n1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.**\n2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.**\n3. *If GCD is selected*, ask the user if they are working in one of the known universes: **S3NS (France)** or **Berlin (Germany)**.\n   - *If S3NS:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.s3nscloud.fr`\n     - Universe API Domain: `s3nsapis.fr`\n     - Universe Name: `s3ns`\n     - Universe Prefix: `s3ns`\n     - Universe Region: `u-france-east1`\n   - *If Berlin:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.berlin-build0.goog`\n     - Universe API Domain: `apis-berlin-build0.goog`\n     - Universe Name: `berlin`\n     - Universe Prefix: `eu0`\n     - Universe Region: `u-germany-northeast1`\n   - *If neither (Custom):* Gather the 5 universe-specific details manually from the user.\n   - *Action:* Present the final list of the 5 universe values to the user for review. Ask for explicit confirmation and offer them the opportunity to change any of the values before proceeding.\n\n### Step 2: Authentication\n\n> [!CRITICAL]\n> **DO NOT START STEP 2 PREMATURELY:** You MUST NOT check authentication, run `gcloud config list`, or execute Step 2 until Step 1 (Environment Assessment & Initialization) has been completely finished and confirmed by the user in previous turns.\n\n1. Ask the user if they are already authenticated with Google Cloud using the correct principal.\n   - *If yes:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use.\n     - *If they confirm (and Step 1 is already completed):* Proceed directly to Phase 2 (Step 3).\n     - *If they do not confirm:* Proceed with the authentication steps below.\n   - *If no:* Proceed with the authentication steps below.\n2. *Standard GCP:* Provide or execute the command:\n   ```bash\n   gcloud auth login\n   gcloud auth application-default login\n   ```\n3. *GCD:* Automate or guide the user through WIF login. Ask for the workforce pool audience string first, then generate the configuration:\n   ```bash\n   # (Use the gathered GCD variables to fill placeholders)\n   gcloud config configurations create <UNIVERSE_NAME>\n   gcloud config configurations activate <UNIVERSE_NAME>\n   gcloud config set universe_domain <UNIVERSE_API_DOMAIN>\n\n   gcloud iam workforce-pools create-login-config <AUDIENCE> \\\n     --universe-cloud-web-domain=\"<UNIVERSE_WEB_DOMAIN>\" \\\n     --universe-domain=\"<UNIVERSE_API_DOMAIN>\" \\\n     --output-file=\"/tmp/wif-login-config-<UNIVERSE_NAME>.json\" \\\n     --activate\n\n   gcloud auth login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json --no-launch-browser\n   gcloud auth application-default login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json\n   ```\n4. Explicitly ask the user to confirm they have successfully authenticated before moving to the next phase.\n","skills/fast/prerequisites/references/phase2-admin-and-baseline.md":"# Phase 2: Admin Principal & Baseline Info\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your responses in Phase 2 prepend the progress block showing:\n>   - Phase 1: Environment & Authentication (2/2 steps completed)\n>   - Phase 2: Admin Principal & Baseline Info (Step 1/2: Admin Principal Definition - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n\n### Step 3: Admin Principal Definition\n\n1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter.\n2. Determine the Admin Principal approach by asking the user to choose between two options:\n   - **Approach A (Preferred): Use a pre-created Group.**\n     - *Action:* Explain that using a group (e.g., `group:gcp-organization-admins@example.com`) is the standard and preferred way. **Crucially, clarify that the group provided MUST be a group that the user's current authenticated identity belongs to**, otherwise they will lock themselves out.\n     - *Action:* Ask the user to provide the group email address.\n     - *Action:* Explicitly ask the user to confirm that their current identity (the one they just authenticated with) is already a member of this group.\n     - *Action:* If the user answers \"No\" to the membership confirmation, **DO NOT PROCEED**. Inform the user that proceeding will lock them out. Ask them to either authenticate with an identity that *is* a member of the group (and restart the authentication step), or provide a different group that their current identity belongs to.\n   - **Approach B (Fallback): Use a Single User.**\n     - *Action:* Explain that this flow uses a single user as the sole GCP Org Admin, but more can be added later.\n     - *Action:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve their current authenticated principal.\n     - *Action:* Show the user their current principal and explicitly ask them to confirm this is the identity they want to use as the Admin Principal.\n\n### Step 4: Baseline Information Gathering\n\n1. Gather baseline information required for `0-org-setup`:\n   - Organization ID (and the associated Directory Customer ID, which is important for Standard GCP but not required for GCD)\n   - Billing Account ID (Mandatory for subsequent stages, even if not required for the GCD temporary project)\n   *Action:* When prompting the user for the Organization ID and Billing Account ID, explicitly instruct them in the prompt/question that they can leave the field blank (or type \"list\") to have you automatically run the relevant `gcloud` command (`gcloud organizations list --format=\"json\"` or `gcloud beta billing accounts list`). Also, instruct them that they can type a keyword to filter the list.\n   *Action:* If the user leaves the field blank or types \"list\", run the `gcloud` command without filters. If the user types a keyword that is not a valid Organization ID (numeric) or Billing Account ID (e.g., `012345-6789AB-CDEF01`), run the `gcloud` command and use that keyword to filter the results using a **case-insensitive** regex match. Ensure the regex pattern is enclosed in single quotes within the filter argument (e.g., `--filter=\"displayName~'(?i)KEYWORD'\"`). Do NOT use `*` wildcards in the filter.\n   *Action:* If the filtered `gcloud` command returns no results, inform the user and ask if they want to provide a different keyword or fetch all items (run without a filter).\n   *Action:* Once you have results for Organizations (filtered or unfiltered), extract the Organization ID, the Display Name (domain), and the Directory Customer ID (found in `owner.directoryCustomerId` in the JSON output). Sort them alphabetically by the display name, and then present the results for the user to make their selection. Note the Organization ID, Domain, and Customer ID for Phase 4. For Billing Accounts, do the same sorting and selection.\n2. Determine the **Admin Principal's** access level to the provided Billing Account ID. Ask which of the following three scenarios applies to the Admin Principal (not necessarily the current user):\n   - **Scenario 1 (Billing Administrator):** The Admin Principal has `roles/billing.admin`.\n     - *Action:* Ask a follow-up question: \"Is your billing account managed by the same organization where we are installing FAST, or outside of it? (You can check this in the Google Cloud Console by going to Billing -> using the organization picker on top -> checking if the account is listed under this organization).\"\n     - *If Inside the Org:* Note that `roles/billing.admin` WILL be assigned at the Organization level in Step 6. Instruct the user that we will deactivate the billing factories path for now, but if account-level IAM also needs to be managed via FAST later, they can reactivate the path and use the billing YAML to do it.\n     - *If Outside the Org:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6.\n   - **Scenario 2 (Billing User):** The Admin Principal has `roles/billing.user` but NOT admin rights.\n     - *Action:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6. Either disable the billing YAML via the `factories_config` variable or comment it out, since the Admin Principal cannot control IAM on the account.\n     - *Action:* **Explain to the user:** The service accounts for IaC (and therefore the provider switch and subsequent stages, except for VPC-SC) will not be operative until the correct billing permissions have been assigned to them outside of FAST.\n   - **Scenario 3 (No Access):** The Admin Principal has absolutely no rights on the billing account.\n     - *Action:* **Clearly state:** This scenario is mostly used for development purposes, is strongly discouraged, and requires advanced Terraform skills and FAST knowledge to proceed.\n","skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md":"# Phase 3: Bootstrap Project & IAM\n\n### Step 5: IAM Role Assignments\n\n1. Grant the following roles to the chosen Admin Principal at the Organization level. **CRITICAL:** Only include `roles/billing.admin` in this list if the user selected Scenario 1 (Billing Administrator) AND confirmed the billing account is managed Inside the Organization in Step 4.\n   **CRITICAL EXECUTION RULE:** Do NOT create temporary bash scripts (e.g., `assign_roles.sh`) to execute this loop. You MUST execute the `for` loop inline directly using the `run_shell_command` tool, or output the exact inline loop for the user to copy/paste.\n   ```bash\n   # Roles to assign:\n   # [roles/billing.admin] <-- CONDITIONAL (See above)\n   # roles/logging.admin\n   # roles/iam.organizationRoleAdmin\n   # roles/orgpolicy.policyAdmin\n   # roles/resourcemanager.folderAdmin\n   # roles/resourcemanager.organizationAdmin\n   # roles/resourcemanager.projectCreator\n   # roles/resourcemanager.tagAdmin\n   # roles/owner\n\n   # Loop example for the user or tool execution:\n   for role in [ROLES_LIST]; do\n     gcloud organizations add-iam-policy-binding <ORG_ID> \\\n       --member=\"<ADMIN_PRINCIPAL>\" --role=\"$role\" --condition=None\n   done\n   ```\n\n### Step 6: Bootstrap Project Setup\n\n1. Explain that a temporary bootstrap project is required to track API quota before organization policies are fully established.\n2. Ask the user if they already have a suitable project they can use for this purpose.\n   - *If yes:* Ask if this project is already configured as the active project in `gcloud`. If the user does not know, run `gcloud config list project --format=\"value(core.project)\"` to check for them.\n     - If it is already configured, fetch the Project ID using `gcloud config list project --format=\"value(core.project)\"`. Explicitly ask the user to confirm if this fetched Project ID is the one they want to use. **Only if they answer \"No\" to this confirmation**, ask them to provide the correct Project ID.\n     - If it is not configured, ask the user to provide the Project ID.\n   - *If no:* Ask the user to use the Cloud Console to create a temporary project (must be linked to the billing account). Ask them to provide the new Project ID once created.\n3. Once the Project ID is provided or fetched, ensure it is set as the default project. If it is not already set, run:\n   ```bash\n   gcloud config set project <TEMP_PROJECT_ID>\n   ```\n4. Enable the required baseline APIs on the project:\n   - The required APIs are: `bigquery.googleapis.com`, `cloudbilling.googleapis.com`, `cloudresourcemanager.googleapis.com`, `essentialcontacts.googleapis.com`, `iam.googleapis.com`, `logging.googleapis.com`, `orgpolicy.googleapis.com`, `serviceusage.googleapis.com`.\n   - *If the project was pre-existing:* Ask the user if they want you to check which services are already enabled.\n     - *If yes:* Run `gcloud services list --enabled --format=\"value(config.name)\"` to get the current list. Compute the delta between the enabled services and the required list. Only run `gcloud services enable <MISSING_APIS>` for the ones that are missing.\n     - *If no:* Run the full `gcloud services enable` command for all required APIs.\n   - *If the project is new:* Run the full `gcloud services enable` command for all required APIs.\n","skills/fast/prerequisites/references/phase4-config-and-wrapup.md":"# Phase 4: Configuration & Wrap-up\n\n### Step 7: Configuration Generation\n\n1. **Explain Datasets:** Briefly explain to the user that FAST uses \"datasets\" (collections of YAML files) that fully describe the design, architecture, resources, and policies applied.\n2. **Select the Dataset:** Ask the user to select the dataset they want to use for their landing zone design.\n   - *If GCD:* Explicitly state that the `classic-gcd` dataset must be used for GCD installations.\n   - *If Standard GCP:* Offer the available datasets and ask them to choose one. **Crucially, only search for available datasets within the `fast/stages/0-org-setup/datasets/` directory.** Do not search across the entire repository or other FAST stages. Provide a one-line description below each dataset when presenting the options (e.g., \"classic: The standard FAST landing zone architecture\", \"hardened: A more restrictive, hardened landing zone architecture\").\n3. **Explain Defaults Configuration:** Explain to the user that we are starting the configuration of the `defaults.yaml` file, which drives the static configuration of the dataset by providing the Org ID, Billing ID, user-specified locations, and any static values they need to bring in from the outside (like additional IAM principals used in the YAML files to assign IAM roles).\n4. **Determine Locations:** FAST uses a set of locations for different services.\n   - *If GCD:* The region is fixed based on the universe selected in Phase 1. Set the `logging` location to `global` and all other required locations to the Universe Region. Do not ask the user to choose; simply show them the configured locations.\n   - *If Standard GCP:* Check the `fast/stages/0-org-setup/schemas/defaults.schema.json` to identify the required location keys (e.g., `bq`, `gcs`, `logging`, `pubsub`). First, ask the user to provide a \"base location\" (e.g., `europe-west1`), explaining that submitting without answering will confirm the default value. Even if the user confirms the base location, you must then ask if they need to override the location for any individual services.\n5. **Determine Local Path:** Explain to the user that FAST generates provider configurations and other files that need to be stored outside the repository. This is defined by the `output_files.local_path` setting. Propose a default path based on the chosen prefix (e.g., `~/fast-config/<prefix>`) and ask the user to confirm or provide a different path. **Crucially, if the user provides a relative path (e.g., `custom-fast-config` or `./custom-fast-config`), use it exactly as provided relative to the current working directory. Do not automatically prepend `~/` to their input.** Once confirmed, create this directory using `mkdir -p <LOCAL_PATH>`.\n6. **Ask for Additional Context:** Ask the user if there are any other static values they want to bring in from outside to be referenced in the YAML files. Show them the available context keys (excluding `condition_vars`). Provide examples showing that prefixes are mandatory for IAM principals (e.g., `user:foo@example.com`, `group:bar@example.com`). For GCD, also show a `principalSet:` example. **Do not use shell commands like `echo` or `cat` to show this list; output it directly in your chat message.**\n7. **Create Local Directories and Copy Defaults:**\n   - Create the `data/0-org-setup/` and `providers/` directories inside the confirmed `local_path` (`mkdir -p <LOCAL_PATH>/data/0-org-setup/ <LOCAL_PATH>/providers/`).\n   - **Copy** (do not move) the `defaults.yaml` from the chosen dataset folder to `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` using `cp`.\n8. **Edit the Copied Defaults File:** Use the `replace` tool to edit the *copied* `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` file.\n   - Populate `global.billing_account`, `global.organization.id`, `global.organization.domain`, and `global.organization.customer_id` (using the values gathered in Phase 2). Note that `customer_id` may not be present for GCD.\n   - Populate `context.iam_principals.gcp-organization-admins` (using the Admin Principal determined in Phase 2).\n   - Populate the service-specific locations gathered in the previous step into the `context.locations` block and map them appropriately in `projects.defaults.locations`.\n   - Populate `output_files.local_path` with the confirmed path.\n   - If additional context was provided, add it to the `context` block.\n   - *If GCD*, ensure the `overrides.universe` block is present with `domain`, `prefix`, and specific identity overrides.\n9. Run YAML validation: `yamllint -c .yamllint --no-warnings <LOCAL_PATH>/data/0-org-setup/defaults.yaml`. Handle missing tool errors as described in the Core Principles.\n10. **Create Configuration Files:**\n    - Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`).\n    - In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `\"datasets/classic\"`), but the `paths.defaults` must point to the absolute path of the copied defaults file.\n    - *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`.\n11. **Present Configuration and Halt:** Briefly tell the user you have created and validated the baseline configuration files. You MUST stop execution immediately, present the generated files, ask the user if they are ready to proceed with **Step 8 (Organization Policy Import Check & Essential Contacts)**, and wait for their response. Do not proceed to Step 8 or run more tools in this turn.\n\n### Step 8: Organization Policy Import Check & Essential Contacts\n\n1. **Organization Policy Import Check:**\n   - Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported.\n   - Provide (or execute if automatic) the command to list current policies:\n     ```bash\n     gcloud org-policies list --organization=\"<ORG_ID>\" --format=\"value(constraint)\"\n     ```\n   - **STOP Execution and Wait:** You MUST stop execution immediately here, ask the user to run the command, and wait for them to paste the output. Do NOT proceed to Step 9 or update any files until the user has explicitly provided the output of this command.\n   - **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them.\n\n2. **Essential Contacts Handling:**\n   - Explain to the user that FAST Stage 0 datasets configure essential contacts as part of the organization configuration, and simultaneously enforce the Essential Contacts domain restriction organization policy (`essentialcontacts.allowedContactDomains`). Any essential contacts defined must match the allowed domains in the organization policy, otherwise Terraform deployment will fail with policy violation errors.\n   - Ask the user to choose how they want to handle essential contacts:\n     - **Option A (Define Essential Contact & Update Org Policy - Recommended):**\n       - Prompt the user for the essential contact email address.\n       - If the currently authenticated identity / deploying principal (from Phase 1/2) is an email-like principal (e.g. `user@example.com` or `user:user@example.com`), offer the option to reuse it.\n       - If the deploying principal is a Workforce Identity Federation (WIF) identity (i.e. starting with `principal://`), skip this option and prompt the user to provide a valid email address.\n       - Update the essential contact in `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` (under `context.email_addresses` or `projects.defaults.contacts`) using the `replace` tool.\n       - Update the organization policy in `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool so that `essentialcontacts.allowedContactDomains` allows the domain part of the provided email (e.g. adding `@<EMAIL_DOMAIN>` to the allowed values).\n     - **Option B (Allow All Domains in Org Policy):**\n       - If the user does not want to restrict domains or wants to bypass domain restrictions, update `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool to set the policy rule to allow all values (`allow: all: true`), commenting out the existing restricted rule setup. Do NOT delete or empty the file (to keep it as a reference and avoid Terraform failures on empty YAML files).\n\n### Step 9: Wrap-up & Apply\n\n1. **Link Configuration:** Instruct the user to link the final configuration files to the stage directory using the `fast-links.sh` script. They should navigate to `fast/stages/0-org-setup/` and run:\n   ```bash\n   ../../fast-links.sh <LOCAL_PATH>\n   ```\n   Explain that they should then copy and run the commands printed by the script to create the links.\n   **Note:** If they are not on GCD, they can ignore the provider file linking command printed by the script for the first run.\n2. Remind the user that the prerequisite phase is complete.\n3. Instruct them to run `terraform init` and `terraform apply`.\n4. Remind them to delete the temporary project and reset their gcloud default project to `iac-0` after a successful apply. Provide the exact commands for them to copy and use later:\n   ```bash\n   gcloud projects delete <TEMP_PROJECT_ID>\n   gcloud config set project <IAC_PROJECT_ID>\n   ```\n","skills/maintenance/release-process/SKILL.md":"---\nname: release-process\ndescription: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n---\n\n# Cloud Foundation Fabric Release Process\n\nThis skill guides maintainers through the process of cutting a new release for the Cloud Foundation Fabric (CFF) repository.\n\n> **CRITICAL:** Release names and tags MUST ALWAYS be prefixed with `v` (e.g., `v55.1.0`, `v56.0.0`).\n\n## Prerequisites\n\n- **Permissions:** You must have write/release permissions on the repository.\n- **GitHub Token:** A valid GitHub token (can be easily derived by running `gh auth token`).\n- **Environment:** You can run the tools using either `uv` (recommended) or a standard Python virtual environment with `tools/requirements.txt` installed.\n\n## 1. Preparation\n\nStart from the `master` branch and ensure you are up to date:\n\n```bash\ngit checkout master\ngit pull\n```\n\nIdentify the **latest released version** by extracting it from the `default-versions.tf` file:\n\n```bash\nLATEST_RELEASE=$(sed -n 's/^# Fabric release: //p' default-versions.tf)\necho \"Latest release is: $LATEST_RELEASE\"\n```\n\n## 2. Generate and Review Changelog\n\nRun the changelog script, specifying the bump type (e.g., `major`, `minor`, `patch`):\n\n```bash\n# Set your bump type here (major, minor, or patch)\nBUMP_TYPE=\"minor\"\n\n# Option A: Using uv (Recommended)\nuv run tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n\n# Option B: Using a standard virtual environment\n./tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n```\n\n**Review the changes:**\nRun `git diff CHANGELOG.md` and carefully inspect the output. Look for:\n\n- PRs missing labels (they will appear under a \"no subsection\" or similar area).\n- Weird or unclear names in PR titles.\n\n**If you find issues:**\n\n1. Click the PR link in the diff.\n2. Manually fix the labels or title on GitHub.\n3. Re-run the `changelog.py` command above.\n4. Repeat until the changelog is clean.\n\n## 3. Bump Versions\n\nOnce the changelog is correct, extract the newly generated release version from the top of the changelog and bump the repository versions:\n\n```bash\nNEW_RELEASE=$(sed -n 's/^## \\[\\([^]]*\\)\\].* - .*/\\1/p' CHANGELOG.md | head -n 1)\n# Ensure it has the 'v' prefix\nif [[ $NEW_RELEASE != v* ]]; then NEW_RELEASE=\"v$NEW_RELEASE\"; fi\necho \"New release is: $NEW_RELEASE\"\n\n# Option A: Using uv (Recommended)\nuv run tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n\n# Option B: Using a standard virtual environment\n./tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n```\n\n## 4. Commit, Open PR, and Merge\n\nBecause `master` is protected and requires pull requests via the merge queue, create a branch, stage all updated files (`CHANGELOG.md`, `default-versions.tf`, `default-versions.tofu`, `fast_version.txt`, and module `versions.tf`/`versions.tofu` files), and open a PR:\n\n```bash\nBRANCH_NAME=\"$(gh api user -q .login)/prep-$NEW_RELEASE\"\ngit checkout -b \"$BRANCH_NAME\"\ngit add -u\ngit commit -m \"prep $NEW_RELEASE\"\ngit push -u origin \"$BRANCH_NAME\"\n\ngh pr create \\\n  --title \"Prep $NEW_RELEASE\" \\\n  --label \"on:FAST,on:modules,on:tools\" \\\n  --body \"Release preparation for $NEW_RELEASE:\n\n- \\`CHANGELOG.md\\`: generated with \\`tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE\\`\n- version strings bumped to \\`$NEW_RELEASE\\` with \\`tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\\`\n\nNo functional changes; every non-CHANGELOG diff is the release string only.\"\n\n# Queue the PR for auto-merge once approved and checks pass\ngh pr merge --auto\n```\n\nOnce the PR is approved and merged into `master`, switch back to `master`, pull the merged commit, and wait for the GitHub Actions workflows on `master` to finish successfully before proceeding to the next step:\n\n```bash\ngit checkout master\ngit pull\n\n# Watch the latest workflow run on the master branch\ngh run watch $(gh run list --branch master --limit 1 --json databaseId -q '.[0].databaseId')\n```\n\n## 5. Create the GitHub Release\n\nYou can create the release either automatically via the GitHub CLI or manually via the GitHub UI.\n\n### Option A: Automated via GitHub CLI (Recommended)\n\nThis script extracts any \"BREAKING CHANGES\" from the changelog and converts the heading to Title Case.\n\n```bash\n# 1. Extract the section for the new release from CHANGELOG.md\n# We use awk to grab everything from the new release header until the next release header\nRELEASE_NOTES=$(awk \"/^## \\[$NEW_RELEASE\\]/{flag=1; next} /^## \\[v/{flag=0} flag\" CHANGELOG.md)\n\n# 2. Extract just the BREAKING CHANGES section (if any)\nBREAKING_CHANGES=$(echo \"$RELEASE_NOTES\" | awk '/^### BREAKING CHANGES/{flag=1; print; next} /^### /{flag=0} flag')\n\n# 3. Format breaking changes\nif [ -n \"$(echo \"$BREAKING_CHANGES\" | tr -d '[:space:]')\" ]; then\n  # Convert \"### BREAKING CHANGES\" to \"## Breaking Changes\"\n  FORMATTED_BREAKING_CHANGES=$(echo \"$BREAKING_CHANGES\" | sed 's/^### BREAKING CHANGES/## Breaking Changes/')\n  echo \"$FORMATTED_BREAKING_CHANGES\"\nfi\n```\n\n> **CRITICAL:** Show the extracted and formatted breaking changes to the user and wait for their approval before proceeding to create the release.\n\nOnce approved, run the appropriate command to create the release.\n\nIf there were breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes --notes \"$FORMATTED_BREAKING_CHANGES\"\n```\n\nIf there were no breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes\n```\n\n### Option B: Manual via GitHub UI\n\nGo to the [GitHub Releases UI](https://github.com/GoogleCloudPlatform/cloud-foundation-fabric/releases/new) and configure the release:\n\n1. **Tag:** Create a new tag matching the new version (e.g., `v56.0.0`).\n2. **Title:** Use the exact same version string as the tag.\n3. **Release Notes:** Click the **\"Generate release notes\"** button.\n4. **Breaking Changes:** If the `CHANGELOG.md` contains a \"BREAKING CHANGES\" section for this release, copy it, paste it at the **top** of the generated release notes, and change the heading to \"## Breaking Changes\".\n\nClick **Publish release**.\n","tools/README.md":"# Cloud Foundation Fabric (CFF) Tools\n\nThis directory contains utility scripts and tools used to automate linting, formatting, validation, documentation generation, and testing across the Cloud Foundation Fabric repository.\n\n## Categorized Tools\n\n### 1. Documentation Generation\nTools for automatically generating documentation tables or formats from source configurations.\n\n- **[tfdoc.py](./tfdoc.py)**: Automatically generates inputs, outputs, and files documentation tables inside module READMEs.\n- **[schema_docs.py](./schema_docs.py)**: Recursively parses and compiles JSON schemas into readable, markdown-based documentation tables.\n- **[format_tftest.py](./format_tftest.py)**: Formats Terraform code blocks containing `# tftest` directives inside markdown files.\n- **[update_schema_links.py](./update_schema_links.py)**: Updates the schema URLs in YAML modelines across a directory to match a specific CDN or versioned source.\n- **[pre-commit-tfdoc.sh](./pre-commit-tfdoc.sh)**: Pre-commit hook script to run `tfdoc.py` updates and verify README alignment.\n\n### 2. Testing, Planning & Emulation\nTools to run testing sandboxes, parse plans, and simulate/evaluate agent behaviors.\n\n- **[skill-turn-harness/](./skill-turn-harness)**: A hybrid Python/SDK-based test harness for developing, running, and grading Antigravity skills.\n- **[generate_plan_summary.py](./generate_plan_summary.py)**: Generates structured plan summaries of resources and their changes for README examples or `tftest.yaml` tests.\n- **[plan_summary.py](./plan_summary.py)**: An internal helper script to parse, format, and filter Terraform plan structures for automated test assertions.\n- **[create_e2e_sandbox.sh](./create_e2e_sandbox.sh)**: Bootstraps an isolated end-to-end sandbox directory to safely provision and test CFF examples.\n\n### 3. Repository Maintenance & Automation\nTools for version management, automated reviews, GCP service definitions, and release automation.\n\n- **[changelog.py](./changelog.py)**: Automates the generation and maintenance of the `CHANGELOG.md` based on Git diffs and version changes.\n- **[versions.py](./versions.py)**: Synchronizes required engine and provider version constraints across standard provider configuration files in the repository.\n- **[build_service_agents.py](./build_service_agents.py)**: Parses the official Google Cloud documentation to build a structured representation of GCP service agents and their properties.\n- **[pr_review.py](./pr_review.py)**: Leverages the Gemini API to perform automated, context-aware code reviews on pull requests.\n- **[state_iam.py](./state_iam.py)**: Parses and displays authoritative IAM binding configurations directly from a local Terraform state file.\n\n### 4. Linting, Quality & Compliance\nTools for maintaining style guide compliance, licensing, validation, and content/rule verification.\n\n- **[check_boilerplate.py](./check_boilerplate.py)**: Scans files to verify that correct Google license headers and boilerplates are present.\n- **[check_documentation.py](./check_documentation.py)**: Recursively verifies that the variables and outputs tables generated inside module `README.md` files are up-to-date.\n- **[check_links.py](./check_links.py)**: Recursively parses Markdown files to validate external links, internal links, and anchor destinations.\n- **[check_names.py](./check_names.py)**: Evaluates name length constraints and formatting for specified GCP Terraform resources to ensure compatibility.\n- **[check_schema_docs.py](./check_schema_docs.py)**: Recursively checks if the Markdown documentation generated from JSON schemas is fully up-to-date.\n- **[check_yaml_schema.py](./check_yaml_schema.py)**: Validates YAML configuration and factory data files against their defined JSON schema modelines.\n- **[duplicate-diff.py](./duplicate-diff.py)**: Verifies content alignment for files that must remain identical across different stages or modules (e.g. schemas, policies).\n- **[lint.sh](./lint.sh)**: A shell script wrapping boilerplate, YAML, and Terraform style and format checks.\n- **[tflint-fast.py](./tflint-fast.py)**: Runs the `tflint` linter against FAST stages by setting them up in temporary isolated environments.\n","tools/skill-turn-harness/README.md":"# Hybrid Python Test Harness for Antigravity Skills\n\n## Overview\n\nThis project provides a robust, hybrid test harness for developing and evaluating Antigravity skills. It solves the \"inner dev loop\" problem by allowing you to test local, unpacked skills directly against the Antigravity engine (via the Python SDK), while using an LLM to deterministically grade the agent's behavior.\n\n## Table of Contents\n\n- [Prerequisites](#prerequisites)\n- [How to Use](#how-to-use)\n  - [Basic Usage](#basic-usage)\n  - [Command Line Options](#command-line-options)\n  - [Expected Output](#expected-output)\n- [Testing Local Skills (Inner Dev Loop)](#testing-local-skills-inner-dev-loop)\n- [Writing Playbooks](#writing-playbooks)\n- [Running the Pytest Suite](#running-the-pytest-suite)\n- [Writing Playbooks: Three Modes of Testing](#writing-playbooks-three-modes-of-testing)\n\nThe architecture relies on three main components:\n\n- **Orchestrator (Python):** Drives the execution loop, reads YAML playbooks, and manages isolated workspaces for each test run to prevent session caching issues.\n- **Execution Target (Antigravity SDK):** The skill is executed using the `google-antigravity` Python SDK, which manages the localharness engine in-process. This eliminates the dependency on a globally installed CLI.\n- **Evaluator (Gemini API):** The semantic evaluation of the agent's output is performed via direct API calls to `gemini-2.5-flash` using `google-genai`. This bypasses brittle string-parsing and guarantees structured JSON output (Pass/Fail + Reasoning).\n\n## Prerequisites\n\nYou can run the harness directly using `uv` (recommended), which will automatically handle downloading and running with the required dependencies:\n\n```bash\nuv run harness.py playbooks/my-playbook.yaml\n```\n\nAlternatively, ensure you have a Python virtual environment set up with the required dependencies:\n\n```bash\npip install google-antigravity google-genai pydantic pyyaml click pytest\n```\n\nYou also need your Gemini API key available in your environment, or stored in `~/.gemini/key.env`:\n\n```bash\nexport GEMINI_API_KEY=\"your_api_key_here\"\n```\n\n## How to Use\n\nThe main entry point is the `harness.py` CLI tool.\n\n> [!NOTE]\n> All commands in this guide assume you are running from the `tools/skill-turn-harness` directory. If running from the repository root, prefix paths accordingly (e.g., `python3 tools/skill-turn-harness/harness.py ...`).\n\n### Basic Usage\n\nTo run a test, provide a YAML playbook:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml\n```\n\n### Command Line Options\n\n- `playbook` (Required): The path to the YAML playbook defining the test steps.\n- `--log-dir <path>` (Optional): The directory where the harness will write detailed Markdown logs, session JSONs, and JSON failure dumps. Defaults to `./logs`.\n- `--skill-src <path>` (Optional): The path to a local, unpacked skill directory. See the \"Testing Local Skills\" section below for details.\n- `--env-file <path>` (Optional): The path to a standard `.env` file containing key-value pairs (e.g. `MY_SECRET=123`). This is used for secure string substitution within your playbook steps.\n- `--keep-workspace` (Optional): Preserve the temporary workspace directory (`/tmp/gemini_harness_*`) after execution to inspect files generated by the agent.\n- `--agent-model <model>` (Optional): Override the model the agent uses (e.g., `gemini-2.5-pro`). Overrides playbook definition.\n- `--evaluator-model <model>` (Optional): Override the model the test harness uses to grade and simulate (e.g., `gemini-2.5-flash`). Overrides playbook definition.\n- `--max-deviations <number>` (Optional): Set the maximum number of minor deviations/mistakes (such as rule violations or incorrect tool calls) the agent can make during autonomous/hybrid mode before the harness fails the test run. Defaults to `3`.\n- `--debug` (Optional): Enable verbose debug logging for the SDK (e.g., WebSocket traffic).\n\n⚠️ **Security Warning regarding Logs:**\nIf your playbooks require secrets (like API keys or passwords) via the `env` array, the harness will substitute them before executing the CLI. Because the harness traces all inputs and outputs for debugging, **these substituted secrets will be written in plain text** to your `logs/` directory.\nA default `.gitignore` is provided in the `logs/` directory to prevent committing these files, but care should still be taken to avoid leaking secrets into your repository.\n\n### Expected Output\n\nThe harness executes the playbook, rendering thoughts and tool calls in real-time, and streams the results to the console with active context usage stats:\n\n```text\n--- Tuning: FAST Setup PoC | Workspace: /tmp/gemini_harness_abc123 ---\n\n[Step 1]\nTester:\nHi, please activate the fast-setup-poc skill and let's configure FAST.\n  🧠 Thinking:\n  Let's activate the fast-setup-poc skill and check the requirements.\n  🛠️ [Tool Call]: list_directory(path=.)\n  ...\n✅ [PASS Step 1]: The agent greeted the user ('Hi'), confirmed it was configuring FAST, and asked for the Project ID. All parts of the objective were fulfilled.\n\n[Step 2] [Context: 4,512]\nTester:\nmy-super-project-123\n...\n\n✅ [SUCCESS] Playbook 'FAST Setup PoC' completed successfully.\n📄 Session JSON saved to: logs/FAST_Setup_PoC_session.json\n📄 Markdown log saved to: logs/FAST_Setup_PoC_log.md\n```\n\nIf a step fails, the harness halts immediately and dumps the full interaction trace to a JSON file (e.g., `logs/FAST_Setup_PoC_failed.json`) for debugging.\n\n## Testing Local Skills (Inner Dev Loop)\n\nWhen developing a complex skill (with multiple markdown files, prompt templates, or tools), you don't want to package and globally install it just to run a test.\n\nThe harness supports testing local skills directly using the `--skill-src` flag:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml --skill-src ./my-local-skill/\n```\n\n**How it works under the hood:**\n\nThe harness passes the skill path to the SDK's `LocalAgentConfig(skills_paths=[...])`. The Antigravity engine loads the skill dynamically for the duration of the session. Unlike the old CLI-based linking, this is completely isolated and does not modify your global environment.\n\n## Workspace Management & Copying Behavior\n\nBy default, the test harness executes the agent in an isolated temporary workspace (e.g., `/tmp/gemini_harness_<hash>`) to prevent session caching and protect your repository from accidental file modifications.\n\n### Copying vs. Symlinking\nTo ensure both safety and compatibility with local search tools (such as the agent's built-in `search_directory` / grep tool, which can crash when traversing directory symlinks), **the harness copies the configured playbook directories recursively instead of symlinking them**.\n\nTo keep the workspace lightweight and prevent the agent from \"cheating\" by reading the test definitions, the copy operation implements strict exclusion rules:\n- **Excluded Dependencies/Cache**: `.terraform`, `.git`, `.venv`, `venv`, `__pycache__`, `.pytest_cache` are skipped. This reduces the copied size of directories like `fast/` from 1.4GB to a few megabytes, making workspace setup near-instant.\n- **Excluded Harness**: The `skill-turn-harness` directory itself is **strictly excluded** from the copy. This prevents the agent under test from walking the workspace, reading the playbook YAML definitions, and \"cheating\" by peeking at the expected inputs/outcomes.\n\n### Linking the `skills` Directory\nIf your autonomous playbook instructs the agent to \"activate\" or \"inspect\" the skill, the model may attempt to search the workspace for the skill's source files (like `SKILL.md`). For these playbooks, ensure you add `skills` to the playbook's `link_paths` so the agent can resolve the lookup locally:\n\n```yaml\ntmpdir:\n  link_paths:\n    - fast\n    - modules\n    - tools\n    - skills # <-- Make sure to include this so the agent can find skill files\n```\n\n### Isolated Chat History\nThe harness configures the SDK to write raw session state directly to the configured `--log-dir` (under `log_dir/chats/`). This ensures that test execution conversations remain isolated and do not pollute your global Antigravity/Jetski desktop history.\n\n## Writing Playbooks\n\nPlaybooks are written in YAML. For autocompletion and validation in VS Code, add the schema annotation to the top of your playbook.\n\nIf your playbook requires environment variables (e.g., secrets), declare them in the `env` array. You can then reference them in your `steps` using `${VAR_NAME}`. If a variable is declared but not found in the environment (or passed via `--env-file`), the harness will safely halt before execution.\n\n```yaml\n# yaml-language-server: $schema=../playbooks/playbook.schema.json\nname: \"My Test Playbook\"\ntimeout: 120\nagent_model: \"gemini-2.5-pro\"\nevaluator_model: \"gemini-2.5-flash\"\nenv:\n  - MY_API_KEY\nsteps:\n  - user_input: \"Hi, activate my-skill and use this key: ${MY_API_KEY}\"\n    expected_outcome: \"The agent should greet the user and acknowledge the key.\"\n```\n\n## Running the Pytest Suite\n\nThis repository includes a `pytest` suite in the `test/` directory to test the harness itself.\n\nTo run the fast unit tests (which mock the CLI execution):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"not e2e\" -v\n```\n\nTo run the full End-to-End (E2E) test (which dynamically links the fixture skill and hits the real Gemini API):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"e2e\" -v\n```\n\n## Writing Playbooks: Three Modes of Testing\n\nThe harness supports three modes of execution depending on how you structure your YAML playbook. The mode is inferred automatically based on the presence of the `steps` and/or `persona` keys.\n\n### 1. Scripted Mode (Unit / Regression)\n**Best for:** Ensuring the exact, rigid state machine of a skill hasn't broken.\n\nYou define a strict, sequential list of `steps`. The harness feeds the `user_input` and checks if the agent's response satisfies the `expected_outcome` via an LLM evaluation.\n\n```yaml\nname: \"FAST Setup PoC - Scripted\"\nsteps:\n  - user_input: \"Hi, let's configure FAST.\"\n    expected_outcome: \"The agent should greet the user and ask for the Project ID.\"\n  - user_input: \"my-super-project-123\"\n    expected_outcome: \"The agent should acknowledge the Project ID and ask for the preferred Region.\"\n```\n\n### 2. Autonomous \"Pond\" Mode (E2E / Fuzz Testing)\n**Best for:** Testing how the skill handles the messy reality of natural language and conversational drift.\n\nInstead of providing a rigid script, you define a declarative **Persona** with a \"Pond\" of knowledge and explicit `success_criteria`. A secondary LLM agent acts as the simulated user, dynamically reading the CLI's outputs, fishing data from the \"pond,\" and generating the next input until the success criteria are met or the `max_turns` limit is reached.\n\n```yaml\nname: \"FAST Setup PoC - Autonomous\"\npersona:\n  initial_user_input: \"Hi, let's configure FAST.\"\n  context: >\n    You are a GCP developer. Your Project ID is my-project-123 and region is europe-west1.\n    Do not volunteer information until the agent explicitly asks for it.\n  max_turns: 10\n  success_criteria:\n    llm_checks:\n      - \"The agent provided a final configuration summary containing the correct project_id and region.\"\n    tool_calls_contain:\n      run_shell_command:\n        - \"gcloud organizations add-iam-policy-binding\"\n    files_exist:\n      - \"0-org-setup.auto.tfvars\"\n```\n\n### 3. Hybrid Fallback Mode\n**Best for:** Testing happy-path compliance while ensuring the agent can recover from unexpected deviations.\n\nIf a playbook defines **both** `steps` and a `persona`, the harness runs in Hybrid mode. It attempts to execute the rigid `steps` first. If the skill deviates or fails a step evaluation, instead of failing the test outright, the harness **falls back** to the autonomous persona. The simulated user takes over the conversation history and attempts to guide the agent back on track to meet the `success_criteria`. If successful, the test returns a `PASS WITH WARNINGS`.\n","tools/skill-turn-harness/docs/DESIGN.md":"# Design Decisions: Test Harness Architecture\n\n## Context\n\nThis document captures architectural decisions and considerations for the `harness.py` test harness.\n\n## LangChain Integration Analysis\n\n*Date: April 15, 2026*\n\nWe evaluated whether to integrate LangChain into the `harness.py` script. The script currently acts as a lightweight testing harness that uses `subprocess` to interact with the Gemini CLI and the native `google.genai` SDK for evaluation using structured outputs (Pydantic).\n\n### Potential Benefits of LangChain\n\n1. **Model-Agnostic Evaluators (Avoiding Self-Bias):**\n   Currently, the harness uses Gemini 2.5 Flash to evaluate the Gemini CLI. To avoid \"self-preference bias\", it is often best practice to use a different model family for evaluation. LangChain's `ChatModel` abstractions would allow swapping the evaluator model easily without rewriting API call logic.\n2. **Built-in Evaluation Frameworks:**\n   LangChain provides a dedicated evaluation module (`langchain.evaluation`). Instead of custom prompts, we could leverage pre-built evaluators (like `CriteriaEvalChain`) that are prompt-engineered to reduce hallucinations and false positives.\n3. **Observability and Tracing (LangSmith):**\n   Integration provides seamless access to LangSmith for logging evaluation runs, inspecting prompts, latency, token usage, and tracking pass/fail rates over time.\n4. **Prompt Management:**\n   LangChain's `PromptTemplate` system offers robust handling for complex evaluation criteria (e.g., few-shot examples, dynamic context).\n\n### Drawbacks and Limitations\n\n1. **Overkill for Current Scope:**\n   The current script is lightweight and readable. LangChain is a heavy dependency that introduces complex abstractions (like LCEL/Runnables), adding bloat and a steeper learning curve.\n2. **Native Structured Outputs are Sufficient:**\n   The native `google.genai` SDK already handles structured JSON outputs via `response_schema=EvaluationResult` efficiently and reliably. LangChain's structured output would merely wrap this existing capability.\n3. **External Agent Execution:**\n   LangChain excels at managing agent memory, tools, and reasoning loops. Since our harness tests an external CLI tool via `subprocess.run`, LangChain cannot orchestrate the agent and is relegated strictly to the role of a grader.\n\n### Conclusion & Recommendation\n\n**Recommendation: Hold off on LangChain for now.**\n\nThe current architecture is elegant, dependency-light, and perfectly suited for its job. The native `google.genai` SDK handles the structured Pydantic evaluation flawlessly.\n\n**When to reconsider LangChain:**\n\n- We need to evaluate the CLI using non-Google models (e.g., Claude, GPT-4) to ensure unbiased grading.\n- We require visual tracking of test runs, prompt versions, and token costs using LangSmith.\n","tools/skill-turn-harness/docs/Hybrid Test Harness Architecture.md":"# **Architecture Document: Hybrid Python/CLI Test Harness**\n\nThis document outlines the architecture for testing the Fabric FAST configuration skill. It uses a hybrid approach, executing the skill in its native CLI environment while maintaining deterministic control via a Python orchestration loop.\n\n## **1\\. The Approach: Hybrid Isolation**\n\nTo accurately test the skill in its target environment while ensuring the reliability of the test harness, the execution and evaluation layers are strictly separated:\n\n* **Orchestrator (Python):** A Python script acts as the absolute authority. It maintains the state machine, reads the playbook, injects inputs, captures outputs, and triggers evaluations.  \n* **Execution Target (Gemini CLI):** The skill is run via the gemini CLI using Python's subprocess module. This ensures the test reflects the actual user environment. State is maintained across steps using the CLI's session management flags (e.g., \\--resume).  \n* **Evaluator (Gemini API):** The semantic evaluation of the CLI's output is performed via direct API calls to Gemini 1.5 Flash. This bypasses the string-parsing unreliability of a CLI and guarantees structured JSON output via Pydantic schemas.\n\n## **2\\. The Execution Loop**\n\nThe Python orchestrator executes the following rigid sequence for each step in a defined playbook:\n\n1. **Injection:** Read the mocked user input and expected outcome from the playbook step.  \n2. **Subprocess Execution:** Invoke the Gemini CLI with the user input and the designated session\\_id. Capture stdout and trap stderr to handle hangs or crashes.  \n3. **Prompt Assembly:** Construct a strict evaluation prompt combining the exact playbook expectation with the raw string response captured from the CLI.  \n4. **Stateless Evaluation:** Call the Gemini API with the evaluation prompt, enforcing a structured output schema (Boolean Pass/Fail and Reasoning).  \n5. **Verdict Enforcement:** If the evaluator returns True, proceed to the next step. If False, immediately halt the loop, dump the interaction trace to a JSON file, and alert the developer.\n\n## **3\\. Implementation Code**\n\nThe following Python script implements the hybrid harness:\n\nimport subprocess  \nimport json  \nimport sys  \nfrom pydantic import BaseModel  \nfrom google import genai  \nfrom google.genai import types\n\n\\# 1\\. Define Strict Evaluator Schema  \nclass EvaluationResult(BaseModel):  \n    passed: bool  \n    reasoning: str\n\nevaluator\\_client \\= genai.Client()\n\ndef invoke\\_skill\\_cli(user\\_input: str, session\\_id: str) \\-\\> str:  \n    \\# Requires the CLI to support a session resume flag for state  \n    command \\= \\[\"gemini\", \"--resume\", session\\_id, \"-p\", user\\_input\\]  \n    try:  \n        result \\= subprocess.run(command, capture\\_output=True, text=True, timeout=30)  \n        if result.returncode \\!= 0:  \n            print(f\"⚠️ \\[CLI ERROR\\]: {result.stderr}\", file=sys.stderr)  \n            return f\"SYSTEM\\_ERROR: {result.stderr}\"  \n        return result.stdout.strip()  \n    except subprocess.TimeoutExpired:  \n        print(\"⚠️ \\[CLI TIMEOUT\\]\", file=sys.stderr)  \n        return \"SYSTEM\\_ERROR: Timeout\"\n\ndef run\\_hybrid\\_tuning\\_loop(playbook\\_name: str, playbook\\_steps: list, session\\_id: str):  \n    print(f\"--- Tuning: {playbook\\_name} | Session: {session\\_id} \\---\")  \n    interaction\\_log \\= \\[\\]\n\n    for step\\_index, step in enumerate(playbook\\_steps):  \n        user\\_input, expected\\_outcome \\= step\\['user\\_input'\\], step\\['expected\\_outcome'\\]  \n          \n        skill\\_response \\= invoke\\_skill\\_cli(user\\_input, session\\_id)  \n        if skill\\_response.startswith(\"SYSTEM\\_ERROR\"): break  \n              \n        eval\\_prompt \\= f\"\"\"  \n        OBJECTIVE: {expected\\_outcome}  \n        ACTUAL RESPONSE: {skill\\_response}  \n        Evaluate if the agent fulfilled the objective.  \n        \"\"\"\n\n        eval\\_response \\= evaluator\\_client.models.generate\\_content(  \n            model=\"gemini-1.5-flash\", contents=eval\\_prompt,  \n            config=types.GenerateContentConfig(  \n                response\\_mime\\_type=\"application/json\",  \n                response\\_schema=EvaluationResult, temperature=0.0  \n            )  \n        )  \n          \n        parsed\\_eval \\= json.loads(eval\\_response.text)  \n        interaction\\_log.append({\"step\": step\\_index \\+ 1, \"input\": user\\_input, \"evaluation\": parsed\\_eval})\n\n        if not parsed\\_eval\\['passed'\\]:  \n            print(f\"❌ \\[FAILURE Step {step\\_index \\+ 1}\\]: {parsed\\_eval\\['reasoning'\\]}\")  \n            with open(f\"{playbook\\_name}\\_failed.json\", \"w\") as f: json.dump(interaction\\_log, f)  \n            return False \n\n    print(f\"✅ \\[SUCCESS\\]\")  \n    return True\n\n## **4\\. Critical Implementation Warnings**\n\n* **Session Data Persistence:** The CLI likely persists session states to disk (e.g., in a local database or JSON file). If you reuse the same session\\_id for consecutive test runs without manually deleting the cache file, the skill will inherit the context of the previous run, causing immediate test failures. You must either generate a UUID for every run or build a cache-clearing mechanism into the Python script.  \n* **Context Window Discipline:** The evaluation prompt is strictly limited to the current playbook objective and the immediate CLI response. Do not feed the entire CLI conversation history to the Evaluator API, as this significantly increases the risk of hallucinated grading."},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"GEMINI.md","isInternal":false,"tokens":4,"sizeBytes":9},{"name":"SKILL.md","path":"skills/contributing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/contributing/SKILL.md","title":"contributing","category":"anthropic-skill","format":"markdown","content":"---\nname: contributing\ndescription: \"End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\"\n---\n\n# Fabric Contribution Flow Skill\n\nThis skill defines the end-to-end workflow for contributing to Cloud Foundation Fabric. It supports two entry modes:\n- **Mode A: Issue Triage & Bug Fix**: You are addressing an assigned or reported GitHub Issue. Start at **Step 1**.\n- **Mode B: Proactive Development & PR Prep**: You are actively building a feature, refactoring code, or preparing an existing branch for a Pull Request. Jump directly to the **Design Sign-off Gate** before Step 2.\n\nIf it is unclear which mode applies, ask the user before proceeding.\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where your judgment is likely to diverge from a maintainer's. Keep mechanical, reversible steps (linting, tests, doc and inventory regeneration) autonomous. Gates are **blocking**: if you are running non-interactively and cannot get an answer, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Triage Disposition** | End of Step 1 (Mode A) | Whether the issue is in scope and worth pursuing: proceed, reject, or escalate. |\n| **Design Sign-off** | Before Step 2 | Approves the proposed design and scope. Mandatory for new features and interface changes; skippable for trivial fixes where the design is self-evident. |\n| **E2E Opt-in** | Step 5 | Whether to run live cloud verification. Providing a sandbox project ID **is** the consent to deploy to it. |\n| **Behavioral Verification Opt-in** | Step 5, after read-back verification | Whether to also verify runtime behavior of the deployed resources, given the described extra time and cost. |\n| **PR Approval** | Step 6 | Reviews the final sanitized PR body before submission. |\n\n---\n\n## Step-by-Step Workflow\n\n```mermaid\ngraph TD\n    M1[Mode A: Issue Triage] --> A[1. Triage Issue]\n    A --> GT{Gate: Triage Disposition}\n    GT -->|Human approves| GD{Gate: Design Sign-off}\n    GT -->|Human rejects| X[Stop / Report Back]\n    M2[Mode B: Proactive Dev] --> GD\n    GD --> B[2. Develop Fix / Feature]\n    B --> C[3. Tests, Lint & Inventories]\n    C --> D[4. Pre-Submission Self-Review]\n    D -->|Issues found| B\n    D -->|Clean| GE{Gate: E2E Opt-in}\n    GE -->|Project ID provided| G[5. Live E2E Verification]\n    GE -->|Skipped| GS{Gate: PR Approval}\n    G -->|Failures| B\n    G -->|Verified| GS\n    GS --> F[6. Commit & Submit Sanitized PR]\n```\n\n### Step 1: Triage the Issue (Mode A Only)\n\n1.  **Retrieve Issue Details**: Use the GitHub CLI to view the issue context.\n    ```bash\n    gh issue view <issue-number>\n    ```\n\n2.  **Explore the Codebase**: Identify the target module (`modules/<module-name>`) or FAST stage (`fast/stages/<stage-name>`) that requires modification.\n\n3.  **Evaluate Fit & Scope**: Assess whether the issue is relevant for Fabric. Ensure it aligns with Fabric's core philosophy (modules should be lean, composable, and represent a single resource type context). Confirm the change has a sufficiently large scope and represents a generic, valuable addition to the module or FAST stage rather than a highly specific, one-off customization.\n\n4.  **Read Provider Documentation**: If the issue involves Google Cloud resources, retrieve and read the documentation for the involved GCP resources or Terraform provider resource/datasource to ensure accurate implementation of its attributes, behaviors, and constraints.\n    *   Start from the provider registry, e.g. <https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_address>.\n    *   **Check the pinned provider range**: the repo pins the `google` provider in [default-versions.tf](../../default-versions.tf). If the feature you are implementing requires provider functionality released after the pinned minimum version, that is a good reason to bump the pin — do it across the board with `uv run tools/versions.py --provider-min-version <x.y.z> --write-defaults`, which rewrites `default-versions.tf` and propagates it to every module and stage `versions.tf` (linting enforces they stay in sync). Never edit version pins in individual modules only.\n    *   Only rely on documentation for functionality available in a **released** provider version: docs from the provider's `main` branch may describe unreleased attributes that no released provider accepts at plan time.\n    > [!WARNING]\n    > Do NOT rely solely on the proposed solution, examples, or partial specifications provided in the issue description. Always retrieve and review the complete documentation schema from the official provider registry.\n    >\n    > **Workaround for Registry Page JS/Redirection Errors**:\n    > If the registry page fails to load properly with your URL-fetching tool (e.g. returns \"Please enable Javascript\" or truncates due to HTML formatting issues), find the source markdown file on GitHub and fetch its raw content using `curl` to a scratch file (the `scratch/` directory is gitignored), using the tag of the provider release whose docs you need (e.g. `v7.40.0`):\n    > ```bash\n    > curl -s https://raw.githubusercontent.com/hashicorp/terraform-provider-google/v7.40.0/website/docs/r/workstations_workstation_config.html.markdown -o scratch/workstation_config_doc.md\n    > ```\n    > You can then search and view it locally to identify all supported arguments and blocks.\n    > Use this information to make an informed decision on which arguments to support. While 100% coverage is not always necessary or desirable, make a deliberate choice to include all arguments that are useful and align with Fabric's composability and simplicity goals, rather than missing them by omission.\n\n5.  **Gate — Triage Disposition**: Present your assessment (target module/stage, fit with Fabric's philosophy, scope evaluation) and let the human decide whether to proceed. If the issue fails the fit test, report your reasoning and stop — do NOT comment on, label, or close the issue yourself; disposition of the issue is a maintainer decision.\n\n### Gate: Design Sign-off (Modes A & B)\n\nBefore writing code, present the proposed design for approval:\n*   The target module(s)/stage(s) and the shape of the variable interface (new/changed variables, whether `context` support is added).\n*   For provider-surface changes: the explicit list of provider arguments you intend to include and exclude, with reasoning.\n*   Whether the change requires bumping the pinned provider version (a repo-wide change — see Step 1.4).\n\nThis gate is **mandatory for new features and interface changes**. For trivial fixes (typos, one-line bugfixes with an obvious solution) where the design is self-evident, state your intent and proceed without blocking.\n\n### Step 2: Develop the Fix or Feature (Modes A & B)\n\n1.  **Align with Fabric Zen**: Ensure the design focuses on composition, encapsulates logical entities (like IAM and log sinks directly inside modules), adopts common interfaces, and keeps code flat and easy to evolve.\n    > [!IMPORTANT]\n    > You MUST strictly follow the design principles and coding conventions defined in [CONTRIBUTING.md](../../CONTRIBUTING.md) and [GEMINI.md](../../GEMINI.md) when designing variables, modules, and factories.\n\n2.  **Set Up the Git Branch**:\n    *   If you are not already on a feature branch, create one named after your username and the feature/fix (e.g., `<username>/<feature-name>`, such as `johndoe/feature-name`):\n        ```bash\n        git checkout master && git pull\n        git checkout -b <username>/<feature-name>\n        ```\n    *   If preparing an existing branch (Mode B), do NOT create a new one; instead make sure it is up to date with `master` (rebase or merge) before submission.\n    *   Direct branches require push access to the repo (granted to regular contributors on request). Without it, use the fork-based flow described in [CONTRIBUTING.md](../../CONTRIBUTING.md); the rest of this workflow is identical.\n\n3.  **Apply Fabric/FAST Design Conventions**:\n    *   **Context Interpolation**: If context support is relevant and needed for the module (e.g., to support resolving symbolic references like `\"$project_ids:myprj\"`), add a `context` variable block and implement `ctx`/`ctx_p` locals in `main.tf`. Do not add it blindly if the module does not benefit from symbolic interpolation.\n    *   **Compact Variables**: Leverage objects with `optional()` attributes to keep user interfaces clean. Prefer `optional()` **without** an explicit default so the provider applies whatever default value it defines; only set a default when module logic needs a known value.\n    *   **Stable State Keys**: Always use maps instead of lists for collection variables to avoid index shifts in Terraform state.\n    *   **Scope Isolation**: Use private locals (prefixed with `_`) for intermediate transformations, reserving module-level locals for values referenced by resources.\n    *   **Locals Separation for Complex Manipulations**: When adding complex conditional strings, string interpolations, or transformations to resource attributes, compute the map in `locals` so the resource block references a clean index like `local.my_map[each.key]`. This keeps resource blocks legible (<79 chars) and avoids cluttering resource definitions.\n    *   **Validate ENUM Variables**: When adding or exposing variables that mirror ENUM values in the underlying Terraform provider, always include a `validation` block to check for allowed values at plan time. Example from `modules/gcs`:\n        ```hcl\n        variable \"storage_class\" {\n          description = \"Bucket storage class.\"\n          type        = string\n          default     = \"STANDARD\"\n          validation {\n            condition     = contains([\"STANDARD\", \"MULTI_REGIONAL\", \"REGIONAL\", \"NEARLINE\", \"COLDLINE\", \"ARCHIVE\"], var.storage_class)\n            error_message = \"Storage class must be one of STANDARD, MULTI_REGIONAL, REGIONAL, NEARLINE, COLDLINE, ARCHIVE.\"\n          }\n        }\n        ```\n\n4.  **Reference Implementations & Knowledge Sources**:\n    *   [compute-vm](../../modules/compute-vm): Ideal reference for compact variable design, optional objects with defaults, and map variables.\n    *   [project](../../modules/project): Best reference for logical entity encapsulation (IAM, log sinks, Shared VPC configurations), complex locals transformation, and context-based interpolation.\n    *   [adrs/](../../adrs/): Architectural Decision Records documenting the rationale behind key design patterns (e.g. the IAM ADR explains the full `iam*` interface). Before designing, build an index of all ADRs with their title and status:\n        ```bash\n        find adrs -name '*.md' ! -name 'README.md' | sort | while read -r f; do\n          st=$(awk '/^## Status/ {getline; while ($0 == \"\") getline; print; exit}' \"$f\")\n          printf '%s | %s | %s\\n' \"$f\" \"$(head -1 \"$f\" | sed 's/^# //')\" \"$st\"\n        done\n        ```\n        Read in full any ADR whose title relates to the area you are changing. Weigh them by status: ignore `Rejected` ones, treat `Draft`/`Proposed` as directional rather than settled, and remember that even implemented ADRs can lag behind the code — on conflict, the current code and golden-path modules are the source of truth.\n\n5.  **Maintain Code Consistency**:\n    *   Always keep variables in `variables.tf` and outputs in `outputs.tf` in strict alphabetical order. For larger modules, variables and outputs can be grouped logically into separate files (e.g. `variables-logging.tf`), each internally alphabetically sorted.\n    *   Limit line length to 79 characters (relaxed for long attribute names and descriptions).\n    *   **JSON Schema & Factory Alignment**: When a change is made to a module that implements a factory (e.g., `cloud-workstations`) or is the base for one (e.g., `net-vpc-factory` to `2-networking`), the schemas MUST be updated (including their `.schema.md` documentation files), and the factory code should be updated to mirror the changed variable surface. Ensure you regenerate the schema documentation by running:\n        ```bash\n        uv run tools/schema_docs.py\n        ```\n    *   **Duplicated Files**: Some files (e.g. schemas, factory test fixtures) are intentionally duplicated across modules and stages, and CI enforces that copies stay identical. When changing such a file, sync all its copies; the duplicate groups are listed in [tools/duplicate-diff.py](../../tools/duplicate-diff.py) — update that list when adding new duplicated files.\n\n6.  **Update or Add Tests**:\n    *   For modules: If the change introduces a new feature or configuration option, update an existing example in the module's `README.md` (or add a new one) to demonstrate its usage. Ensure the example includes `# tftest` parameters and update the corresponding inventory YAML files under `tests/modules/<module_name>/examples/`.\n    *   For FAST stages: Update `tftest.yaml` scenarios and tfvars/yaml inventories under `tests/fast/...`.\n    > [!NOTE]\n    > Module directories use hyphens (`modules/net-vpn-ha`) while test directories generally use underscores (`tests/modules/net_vpn_ha`). Always check the actual test directory name — a mistyped path makes `pytest` silently collect zero tests.\n\n### Step 3: Run Tests, Linting & Inventory Regeneration (Modes A & B)\n\nRun Python tooling through `uv run` — the scripts in `tools/` declare their dependencies inline, so no virtual environment setup is needed. For faster Terraform testing, always set `TF_PLUGIN_CACHE_DIR=/tmp/tfcache`.\n\n1.  **Run Unified Linting**: Execute `tools/lint.sh` to check copyright boilerplates, Terraform format (`terraform fmt`), alphabetical sorting, and schema validations:\n    ```bash\n    uv run --with-requirements tools/requirements.txt tools/lint.sh\n    ```\n\n2.  **Update Documentation**: If you changed variables or outputs, check consistency and regenerate the README documentation tables:\n    ```bash\n    uv run tools/check_documentation.py modules/<module-name>\n    uv run tools/tfdoc.py --replace modules/<module-name>\n    ```\n\n3.  **Run Impacted Tests**: Execute `pytest` on the target module/stage (using the plugin cache directory for speed):\n    ```bash\n    mkdir -p /tmp/tfcache\n    # tftest.yaml scenarios (note: underscores in the test directory name)\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest tests/modules/<module_name>\n    # README example tests\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest -k 'modules and <module-name>:' tests/examples\n    ```\n\n4.  **Regenerate Test Inventories**: If module-level tests (`tftest.yaml`) or README example inventories fail due to intentional plan output changes, regenerate them using `generate_plan_summary.py`:\n    ```bash\n    # For module-level tftest.yaml inventories:\n    uv run tools/generate_plan_summary.py tests/modules/<module_name>/tftest.yaml <test-name> --save\n\n    # For README.md example inventories:\n    uv run tools/generate_plan_summary.py modules/<module-name>/README.md \"<Example Heading>\" --save\n    ```\n    > [!CAUTION]\n    > `--save` overwrites the assertion baseline, so a regenerated inventory makes the test pass **by construction**. After regenerating, diff the inventory against the previous version (`git diff tests/`), verify that every changed line maps to the intended feature or fix, and report the inventory diff to the user. If a changed line is not explained by your change, treat it as a bug in your code, not a baseline to save.\n\n### Step 4: Pre-Submission Self-Review (Modes A & B)\n\nReview your own diff (`git diff HEAD`, or `git diff master...HEAD` for an existing branch) against the repository guidelines (`GEMINI.md`, `CONTRIBUTING.md`) before submission. This is a pre-flight self-check; the canonical automated review runs in CI after the PR is opened (`Automated PR Review` workflow) — do NOT imitate its output format or header.\n\nCheck the diff against this checklist:\n\n*   [ ] Naming conventions respected (variables, outputs, resources).\n*   [ ] Context support (`ctx` variables) present where the module warrants it.\n*   [ ] IAM follows the standard `iam*` interface patterns.\n*   [ ] Examples with `# tftest` parameters cover the new/changed behavior.\n*   [ ] **CRITICAL TESTING RULE**: if resource blocks were modified (adding a new argument or modifying an existing one), the Terraform plan output changes — the corresponding test inventory YAML files (`tests/.../*.yaml`) MUST be updated in the diff. If they are not, this is a critical testing failure.\n*   [ ] JSON schemas and factory code consistent with the changed variable surface.\n*   [ ] Documentation tables regenerated (`tfdoc`), alphabetical order preserved.\n*   [ ] **Breaking Changes**: If any variable or YAML interface changed in a backwards-incompatible way, verify the PR description draft includes the `**Breaking Changes**` / `upgrade-note` block.\n\nReport the outcome as a short plain-text list of issues found (or \"no issues found\") under a `Pre-submission self-review` heading — no emojis, no status tables. Fix any issue and loop back to Step 3 until the checklist passes.\n\n### Step 5: Live Verification — E2E Sandbox & Policy Troubleshooter (Modes A & B — Optional / Recommended)\n\nWhen code modifications affect GCP resource structures or APIs, run a live E2E sandbox deployment test. Run it only once the diff is stable (tests and self-review pass) to avoid repeated cloud deployments.\n\n1.  **Gate — E2E Opt-in**: Ask the user for a GCP project ID (and if applicable, parent folder / billing account details) for E2E sandbox testing, stating explicitly that providing the project ID authorizes you to deploy and destroy resources in it with `-auto-approve`. If no project is provided, skip this step entirely.\n\n2.  **Verify Credentials**: Confirm `gcloud` authentication and Application Default Credentials are available (`gcloud auth list`) before attempting any deployment, and surface a clear error rather than failing mid-apply.\n\n3.  **Create Sandbox Directory**: Create a temporary sandbox folder under `scratch/e2e_sandbox/` (the `scratch/` directory is gitignored).\n\n4.  **Generate Test Configuration**:\n    *   Generate a root Terraform module `main.tf` in the sandbox directory.\n    *   **CRITICAL**: The `source` argument of the module call MUST point to the **local path** of the modified module in the repository (e.g. `source = \"../../modules/<module-name>\"`), NOT the GitHub reference, to ensure your local changes are tested.\n    *   Set up necessary providers and variables.\n\n5.  **Deploy**:\n    *   Run `terraform init` and `terraform apply -auto-approve` in the sandbox folder.\n    *   Confirm that all resources are created successfully. A clean apply is the baseline, not the goal: it only proves the API accepted the request, not that the change behaves as intended.\n\n6.  **Read-Back Verification (required)**:\n    *   Verify the deployed state through the service's read API (e.g. `gcloud compute backend-services describe`, or the equivalent `GET`/`describe` surface for the resource), not just the Terraform state or plan output.\n    *   For every field or block touched by the change, confirm the live resource contains the intended value and structure (e.g. one API list entry per input element, correct nesting, no silently dropped attributes).\n\n7.  **Behavioral Verification (optional — Gate)**:\n    *   **Gate — Behavioral Verification Opt-in**: after read-back verification passes, explicitly ask the user whether to also verify runtime behavior, describing what the check would entail for this specific change (resources involved, expected extra time such as propagation delays, any additional cost). Proceed only on explicit approval; skipping is a valid outcome and must be recorded in the PR body.\n    *   Verify the change behaves as expected from the perspective of a consumer of the deployed infrastructure. Examples:\n        *   Compute instances: connect to the instance (SSH/serial) and check it is reachable and its startup configuration behaves as expected.\n        *   Networking, load balancers, DNS: send traffic through the deployed path and verify routing, reachability, and response attributes (e.g. `curl` through a load balancer and inspect status and headers, resolve records, test connectivity across peerings or VPN tunnels).\n        *   Serverless and workloads (Cloud Run, Cloud Functions, GKE): invoke the deployed service and verify the response.\n        *   Data services (GCS, BigQuery, Pub/Sub): perform a representative read/write operation (upload an object, run a query, publish and pull a message).\n    *   Account for propagation delays (load balancers, CDN, IAM can take several minutes); retry before concluding failure.\n    *   **IAM conditional bindings**: use Google Cloud's official IAM Policy Troubleshooter as the behavioral check (`gcloud policy-troubleshoot iam`) to verify runtime evaluation against live target resources.\n    *   **CRITICAL**: GCP IAM runtime evaluates `resource.name` using numeric Project Numbers. Always test conditions against `--resource-name` with the numeric Project Number:\n        ```bash\n        gcloud policy-troubleshoot iam //logging.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --permission=logging.buckets.write \\\n          --principal-email=<TEST_PRINCIPAL_EMAIL> \\\n          --resource-name=projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --format=\"json(access,explainedPolicies[].bindingExplanations)\"\n        ```\n\n8.  **Destroy Resources**:\n    *   Once verified, run `terraform destroy -auto-approve` to tear down all created resources and avoid ongoing cloud costs.\n    *   Delete the sandbox directory.\n    *   If verification surfaced failures, fix the code and loop back to Step 3 before re-deploying.\n\n### Step 6: Commit & Submit the PR (Modes A & B)\n\n1.  **Sanitize Before Committing**: PII sanitization applies to **everything that leaves your machine** — commits, file contents, and the PR body. Never commit files containing real GCP project IDs, numeric project numbers, personal email addresses, or custom resource names from live testing; verify `git status` shows no stray sandbox or scratch files before staging.\n\n2.  **Commit and Push**: Stage the relevant files, commit with a short imperative message describing the change, make sure the branch is up to date with `master` (rebase if needed), and push:\n    ```bash\n    git add <files>\n    git commit -m \"Add <feature> to modules/<module-name>\"\n    git push -u origin <username>/<feature-name>\n    ```\n\n3.  **Format the PR Title**: Do NOT use Conventional Commits format (no `feat:` or `fix:` prefixes). Use a short, capitalized, imperative title (e.g., \"Add native tag bindings support to `modules/net-firewall-policy`\").\n\n4.  **Write the PR Body**:\n    *   Explain the problem, rationale, and the fix clearly.\n    *   **Document Verification & E2E Testing Methodology**: Detail local unit tests and, for E2E runs, the apply, read-back, and behavioral verification results (or note that behavioral verification was skipped) so reviewers can see the exact testing rationale and methodology.\n    *   **CRITICAL PII SANITIZATION**: Before writing the PR description, **MUST scrub all developer PII** (real GCP project IDs, numeric project numbers, personal email addresses, usernames, and custom bucket/resource names) and replace them with generic placeholders (e.g., `my-project`, `123456789012`, `user:tester@example.com`, `test-bucket`).\n    *   **Breaking Changes & Upgrade Notes**: Whenever a change requires users to upgrade their variable or YAML surface (e.g. variable type changes, deleted attributes, changed schema structures), you **MUST append** a `**Breaking Changes**` section with an `upgrade-note` code fence at the bottom of the PR description. This is automatically parsed by `tools/changelog.py` when generating release notes.\n        Format:\n        ````markdown\n        **Breaking Changes**\n\n        ```upgrade-note\n        `modules/organization`: `custom_roles` variable type changed from `map(list(string))` to `map(object({...}))`. Existing code passing permission lists directly must be updated to pass `{ permissions = [...] }`.\n        ```\n        ````\n    *   Do NOT touch `CHANGELOG.md`: release notes are generated from PR labels and `upgrade-note` blocks by maintainers using `tools/changelog.py`.\n\n5.  **Gate — PR Approval**: Present the final sanitized PR title and body to the user and get explicit approval before creating the PR.\n\n6.  **Create the PR**:\n    *   **CRITICAL PITFALL**: Do NOT pass the body inline on the CLI if it contains backticks (e.g. `gh pr create --body \"Fixes `bug`\"`) as the shell will evaluate backticks, corrupting the description. Do NOT use shell redirection or heredocs to create the body file either (see `GEMINI.md`).\n    *   Instead, write the body to a temporary file **using your file-writing tool**, then reference it:\n        ```bash\n        gh pr create --title \"Your PR Title\" --body-file /tmp/pr-body.md\n        ```\n","frontmatter":{"name":"contributing","description":"End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request."},"isInternal":false,"tokens":5698,"sizeBytes":25468},{"name":"conventions.md","path":"skills/fabric-builder/references/conventions.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-builder/references/conventions.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Cloud Foundation Fabric (CFF) Conventions for Module Consumers\n\nWhen generating Terraform code that consumes Cloud Foundation Fabric modules, you MUST adhere to the following conventions:\n\n## 1. Module Preference\n- **Prefer Modules:** Always prefer CFF modules over raw `google_*` resources.\n- **Flat Structure:** Avoid creating wrapper modules or nested module calls (modules calling other modules). Consume CFF modules directly in your root module.\n- **Module Sources:** Ask the user whether they want to use relative local paths or absolute paths referencing the GitHub repository.\n  - Relative local paths: `source = \"./modules/project\"`\n  - GitHub repository paths: `source = \"github.com/GoogleCloudPlatform/cloud-foundation-fabric//modules/project?ref={VERSION}&depth=1\"` (include version ref and `depth=1`).\n  - All modules used must specify the same version tag. By default, unless the user states otherwise, default to using the latest release tag (obtainable via `python3 scripts/fabric.py release`).\n\n## 2. Naming Conventions\n- **Use `prefix`:** For modules that support it (e.g., `project`, `gcs`), using a `prefix` variable is recommended but not mandatory.\n- **Deterministic Naming:** Prefer using structured, deterministic tokens rather than random strings.\n\n## 3. Dependency Management\n- **Output-to-Input:** Pass outputs from one module directly as inputs to another (e.g., `network = module.vpc.name`).\n- **Ordering:** CFF modules encapsulate dependencies (like API activation) in their outputs. Rely on these outputs to ensure correct creation order.\n- **Service Agents:** Use the `service_agents` output from the `project` module when granting IAM roles to Google-managed service accounts.\n- **Implicit Dependencies:** Avoid explicit `depends_on` unless absolutely necessary. Rely on implicit dependencies (passing outputs to inputs) for better readability and maintainability.\n\n## 4. Factories\n- **When to Use Factories:** Use factories when you need to manage a large number of similar resources (e.g., projects, VPCs, firewall rules) without duplicating module blocks. Factories separate configuration data (in YAML files) from Terraform logic.\n- **Main Modules with Factory Support:**\n  - `project-factory`: For bulk creation of projects, folders, and budgets.\n  - `net-vpc-factory`: For bulk creation of VPCs and associated resources.\n  - `net-vpc`: Supports loading subnets and internal ranges from folders via `subnets_folder` and `internal_ranges_folder` keys in `factories_config`.\n  - `net-vpc-firewall`: Supports loading firewall rules from a folder via `rules_folder`.\n  - `organization` and `folder`: Support loading organization policies and custom roles.\n  - `vpc-sc`: Supports loading access levels and service perimeters.\n- **Usage:** Pass the path to the directory containing YAML files to the `factories_config` variable of the respective module.\n\n## 5. Style for Root Modules\n- **File Structure:** Make file structure dependent on size. For small configurations, use a single `main.tf`. For larger configurations, split into multiple files grouped by resource type (e.g., `main.tf` for general elements, `networking.tf`, `compute.tf`, etc.).\n- **Variables & Defaults:**\n  - Define all variables in `variables.tf`, sorted alphabetically.\n  - Set defaults directly in the `default` attribute if a reasonable default exists or if provided by the user.\n  - Avoid creating a `terraform.tfvars` file unless explicitly requested by the user.\n- **Value Handling & Providers:**\n  - **No Hardcoded Values:** Never use hardcoded values for project IDs, folder IDs, or other specific identifiers unless provided by the user. If a value is required, ask the user for it or create a variable.\n  - **Provider Attributes:** Do not configure provider-level attributes like `project`, `region`, or `zone` in provider blocks (`provider \"google\"`, `provider \"google-beta\"`). All attributes must be passed to the modules explicitly in the module calls.\n- **Formatting:** Adhere to standard Terraform formatting and keep line lengths readable. Wrap complex ternaries in parentheses.\n- **No Local Exec:** Never use `local-exec` or similar provisioners to run shell commands. Rely on native Terraform resources and data sources, preferably from official providers (i.e., no third-party providers).\n\n## 5a. Version Constraints\n- **Omit Root Version Constraints:** Do not include version constraints (for Terraform or providers in `required_providers` or `required_version`) in the root module.\n- **Automatic Resolution:** Let Terraform automatically compute and resolve the required provider and Terraform versions based on the constraints defined within the consumed CFF modules.\n\n## 6. Impersonation and Backend Management\n- **Impersonation:** If the user requires service account impersonation, add the `impersonate_service_account` attribute to the `provider \"google\"` and `provider \"google-beta\"` blocks in `providers.tf`.\n- **Remote Backend:** If the user wants to use a remote backend, prefer `gcs`. Put the backend configuration in the `terraform` block inside `providers.tf`. If impersonation is used, also set it for the backend.\n\n**Template for `providers.tf`:**\n```terraform\nterraform {\n  backend \"gcs\" {\n    bucket                      = \"${bucket}\"\n    impersonate_service_account = \"${service_account}\"\n  }\n}\n\nprovider \"google\" {\n  impersonate_service_account = \"${service_account}\"\n}\n\nprovider \"google-beta\" {\n  impersonate_service_account = \"${service_account}\"\n}\n```\n","isInternal":false,"tokens":1188,"sizeBytes":5502},{"name":"SKILL.md","path":"skills/fabric-builder/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-builder/SKILL.md","title":"fabric-builder","category":"anthropic-skill","format":"markdown","content":"---\nname: fabric-builder\ndescription: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n---\n\n# Fabric Builder\n\nThis skill generates idiomatic Terraform code using Cloud Foundation Fabric (CFF) modules, following established best practices and conventions.\n\n## Core Workflow\n\n1. **Understand Request:** Identify the GCP resources and relationships requested by the user.\n2. **Fetch Module Info:** Identify the relevant CFF module(s) from the `modules/` folder on GitHub (`GoogleCloudPlatform/cloud-foundation-fabric`).\n3. **Generate Terraform:** Output an idiomatic Terraform root module that consumes the CFF modules according to [conventions.md](references/conventions.md).\n\n## Important Guidance\n\n- **Conventions & Best Practices:** Consult [conventions.md](references/conventions.md) for guidelines on how to consume Fabric modules and write high-quality, idiomatic Terraform code (including module sources, version constraints, and provider configuration).\n- **Fetching Modules:** Do not invent module inputs or outputs. Use the `fabric.py` script to pull exact details (README, variables, examples) for a specific module from GitHub before using it.\n  - To list available modules: `python3 scripts/fabric.py modules`\n  - To fetch details for a module (README): `python3 scripts/fabric.py fetch readme <module_name>`\n  - To fetch variables files: `python3 scripts/fabric.py fetch variables <module_name>`\n  - To fetch outputs files: `python3 scripts/fabric.py fetch outputs <module_name>`\n  - To fetch schema files (useful for factories): `python3 scripts/fabric.py fetch schemas <module_name>`\n  - To fetch the latest release version: `python3 scripts/fabric.py release`\n  - **SSL Certification Verification**: If you run into SSL certificate issues on your host machine, use the `--no-ssl-verify` flag (e.g., `python3 scripts/fabric.py --no-ssl-verify fetch readme <module_name>`).\n\n## Guidelines for Output\n\n- **Root Module Output:** Your output must be a complete Terraform root module that calls the appropriate CFF modules to fulfill the user's requirements.\n- **Follow Conventions:** Strictly adhere to [conventions.md](references/conventions.md) for module sources, version constraints, provider configuration, and coding style.\n- **Use Fabric Modules:** Rely on CFF modules instead of raw `google_` resources whenever possible.\n- **Example-based Learning:** Always refer to the module's README (fetched via `scripts/fabric.py`) for correct usage examples.\n- **Formatting & Validation:** Ensure the generated code is properly formatted. If possible, run `terraform fmt`, `terraform validate`, and `terraform plan` to check your work.\n","frontmatter":{"name":"fabric-builder","description":"Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud."},"isInternal":false,"tokens":597,"sizeBytes":2789},{"name":"COVERAGE.md","path":"skills/fabric-importer/COVERAGE.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/COVERAGE.md","title":"Fabric-importer Skill","category":"anthropic-skill","format":"markdown","content":"# Import maturity matrix\n\nMaturity ladder — every resource family is at exactly one level:\n\n| Level | Meaning | Exit criterion |\n|---|---|---|\n| **N** — named | Appears in an example manifest; no mapping rules yet | — |\n| **C** — cookbook | Mapping rules + import IDs + module addresses derived from module source; not yet plan-tested live | cookbook section exists |\n| **V** — verified | Clean imports, both gates green, address table confirmed live against real Google Cloud resources | verified across live rounds |\n\nVerification target: everything in the four FAST-stage example\nmanifests at **V**. Maturity decays — every V carries the provider and\nFabric ref it was verified against; re-verify on bumps (see\n\"Regression habit\").\n\n## Matrix\n\n| Family | Module | Level | Status / Notes |\n|---|---|---|---|\n| Org IAM + audit configs | organization | **V** | Seeded audit configs, `${` escaping verified |\n| Org policies (incl. dry-run, parameters, custom constraints) | organization | **V** | Three-stream denominator enumeration verified |\n| Custom roles | organization | **V** | From-scratch and modified roles verified |\n| Org log sinks | organization | **V** | Destinations graduated to typed module references |\n| Folders + folder IAM | folder (per instance) | **V** | Parent chaining and in-place rename verified |\n| Projects, services, project IAM | project (per instance) | **V** | Project services and IAM bindings verified |\n| Service accounts + impersonation IAM | iam-service-account | **V** | Leaf-IAM opt-in verified |\n| VPC networks / subnets / routes | net-vpc | **V** | ForceNew alignment and subnet address mapping verified |\n| Log buckets (sink destinations) | logging-bucket | **V** | Reference graduation verified |\n| GCS buckets | gcs | **V** | State buckets, ForceNew project ID prefix verified |\n| BigQuery datasets | bigquery-dataset | **V** | Datasets, import IDs, `description = null` alignment verified (tables/views/routines unverified) |\n| Pub/Sub topics | pubsub | **V** | Topics, import IDs, sink reference graduation verified (subscriptions/schemas unverified) |\n| DNS zones | dns | **V** | Private zones, VPC reference link verified |\n| DNS policies / response policies | dns | **N** | Named in `examples/import-manifest.fast-networking.yaml`; no mapping rules yet |\n| NCC spokes (linked-VPC) | raw resource — **capability gap**: no Fabric module (`ncc-spoke-ra` is router-appliance only) | **V** (raw) | Upstream-issue candidate, lift with `moved {}` when a module appears |\n| Cloud Routers / NAT | net-cloudnat | **V** | Integrated router + NAT verified |\n| Firewall policies | net-firewall-policy | **C** | Mapping rules documented in cookbook (Hierarchical, Global, and Regional Network policies) |\n| VPN / Interconnect attachments | net-vpn-*, net-vlan-attachment | **C** | Mapping rules documented in cookbook |\n| KMS keyrings / keys + key IAM | kms | **V** | Seeded keyring, key, leaf-IAM on CryptoKey verified; permanent residue documented |\n| Certificate Authority Service | certificate-authority-service | **V** | CA pools: CAI type, import ID, pool address (`google_privateca_ca_pool.default[0]`), `ca_configs = {}` default-CA trap, and `publishing_options` support verified on Fabric ref containing PR #4107 (commit `a153861aae`, released in `v58.0.0`). Note: on releases lacking #4107 (`v57.0.0` and earlier), CAS reverts to capability gap |\n| VPC-SC policy / perimeters | vpc-sc | **V** | Live AccessPolicy adopted, AccessLevel, dry-run ServicePerimeter; bare-numeric policy import ID verified; `unknown`-level classification handled in `inventory.py`. Enforced perimeters (`status` block) unverified |\n| Tags (keys/values/bindings) | organization / folder / project tags | **V** | TagKey, TagValue, TagBinding, import IDs, address shapes, verified at organization, folder and project level. The `templatestring` escaping matrix (`$$${`) is DERIVED FROM MODULE SOURCE, not exercised — no seeded value contained `${` (tag IAM unverified) |\n| Billing account IAM | billing-account | **N** | — |\n| PAM entitlements | organization / folder / project `pam_entitlements` factory | **N** | Entitlements are configuration and importable. PAM **grant bindings** never enter the denominator: `inventory.py` sweeps active grants via CAI (`privilegedaccessmanager.googleapis.com/Grant`) whenever IAM is collected and strips matching bindings deterministically (target, role, requester from the grant itself), stamping them in `_meta.pam_grant_exclusions`. Structural exemption, not a waiver; declaring the Grant type in a manifest is refused. Strip logic unit-tested; not yet exercised against a live grant |\n| IAM deny policies | organization / folder / project | **N** | Not a CAI asset type; enumerated automatically by the built-in `gcloud iam policies list --kind=denypolicies` enumerator. Command shape verified, payload shape not yet exercised live |\n| Log exclusions | organization / folder / project | **N** | Neither a CAI asset type nor a gcloud command group; needs REST enumeration recorded in the run report |\n| Workload identity pools / providers | project identity-providers | **V** | WorkloadIdentityPool, WorkloadIdentityPoolProvider, import IDs, address shapes, 30-day soft-delete residue, and org policy constraints verified |\n| Anything else | — | fallback | documented raw-resource fallback + capability-gap report |\n\n## Verification Notes & Caveats\n\n- **Additive IAM default**: all V-level IAM rows above were verified\n  under **authoritative** emission (`iam` / `iam_bindings` maps), which\n  is now the opt-in (`emission.iam: authoritative`). The additive\n  default (`iam_bindings_additive` → `google_*_iam_member.bindings`)\n  is at **C**: addresses and import-ID formats derived from module\n  source and provider docs, not yet exercised live. First verified\n  additive round graduates it and re-stamps.\n- **Factory emission (opt-in)**: all matrix levels above are for the\n  default per-instance emission. The `factory` emission mode (manifest\n  `emission:` block; cookbook \"Factory emission (opt-in)\") is at **C**\n  across all carriers — address shapes and layout rules derived from\n  module source in-repo, no live import exercised yet. First verified\n  run graduates the exercised carrier and stamps it here.\n\n- **CAS & Custom Roles**: CAS `publishing_options` requires Fabric PR #4107 (`a153861aae`). Custom roles title/description convergence requires Fabric PR #4102. Both are released in `v58.0.0`.\n- **Non-CAI types**: CAI is the default source of the denominator, not its boundary. Types it does not model are enumerated with `gcloud` — automatically where `inventory.py` ships a built-in enumerator, otherwise via a manifest `enumerate:` block — or, where gcloud has no container-scoped surface, out of band against the REST API. All of it merges into the same denominator, and `inventory.py` stops the run rather than proceeding without a type it cannot enumerate. See `references/cai-blind-spots.md`.\n- **Provider Label Inheritance**: Provider v5/v6/v7 generates computed `terraform_labels: {} -> {...}` diffs for resources with inherited provider labels. These are accounted for via scoped rules in `scripts/benign-drift.yaml`.\n\n## Regression habit\n\nOn every provider minor bump, Fabric ref bump, or after long dormancy:\nre-run both gates on the persisted workspace. New provider artifacts\nfail loudly as residuals; resolve via value-guarded benign rules in\n`scripts/benign-drift.yaml`, then re-stamp `verified_against`. Never widen\na rule to make a bump pass.\n","isInternal":false,"tokens":1745,"sizeBytes":7565},{"name":"import-manifest.fast-networking.yaml","path":"skills/fabric-importer/examples/import-manifest.fast-networking.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.fast-networking.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-networking: the\n# networking projects and the connectivity stack.\n#\n# Maturity: core VPC surface, DNS zones, Routers/NAT, and linked-VPC NCC\n# spokes are [V]; advanced policies and VPN are [C]/[N]. Scope to your\n# networking folder. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/333333333333         # networking folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: compute.googleapis.com/Network                   # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork                # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Route                     # [V] (dynamic routes waived)\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone                   # [V] modules/dns\n        levels: [project]\n      - type: dns.googleapis.com/Policy                        # [N] DNS server policies\n        levels: [project]\n      - type: dns.googleapis.com/ResponsePolicy                # [N]\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Hub           # [N] NCC hub\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Spoke         # [V] NCC linked-VPC spoke (raw fallback)\n        levels: [project]\n      - type: compute.googleapis.com/Router                    # [V] modules/net-cloudnat (router + NAT)\n        levels: [project]\n      - type: compute.googleapis.com/Address                   # [N] reserved addresses\n        levels: [project]\n      # - type: compute.googleapis.com/FirewallPolicy          # [C] net-firewall-policy\n      #   levels: [folder]\n      # - type: compute.googleapis.com/VpnGateway              # [C] net-vpn-*\n      #   levels: [project]\n      # - type: compute.googleapis.com/VpnTunnel               # [C]\n      #   levels: [project]\n      # - type: compute.googleapis.com/InterconnectAttachment  # [C] vlan attachments\n      #   levels: [project]\n","isInternal":false,"tokens":711,"sizeBytes":3210},{"name":"import-manifest.fast-org-setup.yaml","path":"skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring the surface managed by FAST stage\n# 0-org-setup: org-level governance plus the IaC / billing-export /\n# observability projects, their service accounts, state buckets, tags, and WIF.\n#\n# Maturity legend (see COVERAGE.md): [V] live-verified  [C] cookbook\n# rules exist, not yet plan-tested  [N] new territory (fallback +\n# capability-gap report). Validate counts with `inventory.py collect`\n# before mapping; adjust the scope's include to your IaC folder/projects.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000   # <- your org id\n    # include/exclude match CAI `ancestors` (NUMERIC project ids).\n    # Restrict project-level types to the org-setup projects:\n    # include:\n    #   - projects/111111111111        # iac-core project NUMBER; see\n    #     import-manifest.multi-domain.yaml for selecting specific\n    #     projects per scope, and import-manifest.org-foundation.yaml\n    #     for the include-vs-exclude numeric-id trap\n    types:\n      - type: iam                                              # [V]\n        levels: [organization, project]\n      - type: org-policy                                       # [V]\n        levels: [organization]\n      - type: iam.googleapis.com/Role                          # [V] (org custom roles)\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink                   # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder       # [V]\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [organization, folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true          # impersonation grants enter the denominator\n      - type: logging.googleapis.com/LogBucket                 # [V]\n        levels: [project]\n      - type: storage.googleapis.com/Bucket                    # [V] state buckets\n        levels: [project]\n      - type: iam.googleapis.com/WorkloadIdentityPool          # [V] WIF federation\n        levels: [project]\n      - type: cloudresourcemanager.googleapis.com/TagKey       # [V] Resource Manager tags\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagValue     # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagBinding   # [V]\n        levels: [organization, folder, project]\n      # --- Optional additional types ---\n      # - type: cloudbilling.googleapis.com/BillingAccount     # [N] billing IAM\n      #   levels: [organization]\n","isInternal":false,"tokens":747,"sizeBytes":3428},{"name":"import-manifest.fast-security.yaml","path":"skills/fabric-importer/examples/import-manifest.fast-security.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.fast-security.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-security: the security\n# projects plus KMS keyrings/keys and Certificate Authority Service (CAS).\n#\n# Maturity (see COVERAGE.md): [V] live-verified.\n# Note: KMS keyrings cannot be deleted in GCP; CAS pools bill monthly.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/222222222222         # security folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing                  # [V] modules/kms\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey                # [V] modules/kms\n        levels: [project]\n        iam: true          # per-key IAM is leaf IAM - opt in explicitly\n      - type: privateca.googleapis.com/CaPool                  # [V] modules/certificate-authority-service\n        levels: [project]\n      # - type: privateca.googleapis.com/CertificateAuthority  # [C]\n      #   levels: [project]\n","isInternal":false,"tokens":469,"sizeBytes":2156},{"name":"import-manifest.fast-vpcsc.yaml","path":"skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 1-vpcsc: the org access\n# policy, access levels, and service perimeters (modules/vpc-sc).\n#\n# Maturity: [V] live-verified (dry-run spec verified live; enforced\n# perimeter status block unverified). Access policies are org-level\n# singletons. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    types:\n      - type: identity.accesscontextmanager.googleapis.com/AccessPolicy      # [V]\n        levels: [organization]\n      - type: identity.accesscontextmanager.googleapis.com/AccessLevel       # [V]\n        levels: [organization, unknown]\n      - type: identity.accesscontextmanager.googleapis.com/ServicePerimeter  # [V]\n        levels: [organization, unknown]\n","isInternal":false,"tokens":303,"sizeBytes":1457},{"name":"import-manifest.multi-domain.yaml","path":"skills/fabric-importer/examples/import-manifest.multi-domain.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.multi-domain.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"multi-domain\" — PER-SCOPE TYPES\n#\n# One estate, four different depths. Each scope carries its own `types:`\n# list — there is no other place to declare types, so what a scope\n# collects is exactly what is written on it. The same type may appear in\n# several scopes with different `levels`, `iam` or `enumerate`; that is\n# the point, not an accident.\n#\n# Fail-closed rules (all exit 1 with the reason, before any API call):\n#   - a scope without a `types:` list is refused;\n#   - `types: []` is refused: an empty list is a scope that collects\n#     nothing, and that is the one thing no scope may say quietly;\n#   - a type whose `levels` cannot intersect its scope's `levels` is\n#     refused as a dead declaration (an entry listing `unknown` is\n#     exempt — see import-manifest.fast-vpcsc.yaml);\n#   - a duplicate `type:` inside one scope's list is refused.\n#\n# Note the asymmetry with `emission:` (also per scope): emission INHERITS\n# built-in defaults for families you omit, because it only decides how\n# collected assets are written. `types:` never inherits anything, because\n# it decides what is collected at all.\n#\n# After collection, read the PER-SCOPE yield tables in inventory.json\n# (`_meta.scopes[].declared_types` / `zero_yield_types`): the aggregate\n# table can be non-zero for a type while one scope's own declaration\n# yielded nothing — e.g. `iam` non-empty org-wide but empty under\n# `workloads`. Every inventory entry also names the scope(s) that\n# collected it (`scopes: [...]`).\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Org foundation: governance only, no project-contained resources.\n  - name: org-foundation\n    root: organizations/000000000000        # <- your org id\n    levels: [organization, folder]\n    emission:\n      folder: factory                       # day-2 operating model\n    types:\n      - type: iam\n        levels: [organization, folder]\n      - type: org-policy\n        levels: [organization, folder]\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  # Networking folder: the connectivity stack, plus folder-level\n  # governance for that subtree only. Same `iam` and `org-policy` types\n  # as above, different levels — per-scope lists make that expressible.\n  - name: networking\n    root: folders/111111111111              # <- networking folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: compute.googleapis.com/Network\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork\n        levels: [project]\n      - type: compute.googleapis.com/Router\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone\n        levels: [project]\n\n  # Security folder: KMS and service accounts, with leaf IAM opted in\n  # per type. `iam: true` is a property of the type ENTRY, so it is\n  # per-scope too: another scope may collect the same CryptoKey type\n  # without leaf IAM.\n  - name: security\n    root: folders/222222222222              # <- security folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey\n        levels: [project]\n        iam: true\n\n  # Two workload projects, named explicitly. Project IAM is in scope\n  # here and nowhere else; no org-policy at all — a deliberate, declared\n  # gap, not a silent one.\n  - name: workloads\n    root: organizations/000000000000\n    levels: [project]\n    include:\n      - projects/333333333333               # app-prod  (project NUMBER)\n      - projects/444444444444               # app-dev\n    types:\n      - type: iam\n        levels: [project]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n\n# ---------------------------------------------------------------------------\n# `enumerate:` blocks live in the list that declares them: a natively\n# enumerated type is swept only in the scopes whose `types:` names it.\n# Repeat the block in every scope that needs it — see\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","isInternal":false,"tokens":1330,"sizeBytes":5770},{"name":"import-manifest.org-foundation.yaml","path":"skills/fabric-importer/examples/import-manifest.org-foundation.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/import-manifest.org-foundation.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"organization foundation\"\n#\n# This manifest reproduces exactly the surface the legacy (v1) pipeline\n# covered: org-level custom roles, org IAM (bindings + audit configs), org\n# policies, org log sinks, the folder hierarchy, and folder IAM.\n#\n# A manifest is the user-owned contract for a fabric-importer run:\n#   - inventory.py collects ONLY what the scopes declare (the completeness\n#     denominator becomes \"what you asked for\", not \"everything\");\n#   - coverage.py fails the run until every collected asset is mapped to a\n#     Terraform address or waived by a human;\n#   - re-runs are incremental: widen a scope's `types:` list (or let the\n#     org grow) and only the delta is added to the workspace. Existing\n#     addresses are never rewritten.\n#\n# The grammar is scopes-only: `scopes:` is a list, and EVERY scope carries\n# its own `types:` list. One estate, different depths per subtree = several\n# scopes, each with its own list — see import-manifest.multi-domain.yaml\n# for the worked multi-scope example and the fail-closed rules.\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Root of the import. organizations/N, folders/N and projects/N are all\n  # valid roots; everything below is evaluated inside this subtree.\n  - root: organizations/000000000000   # <- replace with your org id\n\n    # Optional: restrict to specific subtrees inside the root. An asset is\n    # in scope only if one of these appears among its ancestors. Omit to\n    # cover the whole root.\n    # include:\n    #   - folders/111111111111\n\n    # Optional: carve out subtrees. Exclusion wins over inclusion.\n    #\n    # NOTE (live-verified): include/exclude match against the CAI\n    # `ancestors` array, which stores NUMERIC ids. Both spellings work:\n    # `projects/123456789` (the project NUMBER) matches directly, and a\n    # project ID is resolved to its number by inventory.py via\n    # `gcloud projects describe`. Prefer the number where you know it —\n    # resolution needs the describe permission, and a project that cannot\n    # be resolved is reported as an enumeration failure rather than\n    # silently matching nothing.\n    # exclude:\n    #   - projects/123456789012\n\n    # Optional: emission style, per resource family. YOUR call — the\n    # scripts do not consume it; it directs step-3 mapping (the\n    # cookbook's \"Factory emission (opt-in)\" section is normative).\n    #   per-instance (default) : one module instance per container.\n    #   factory                : data-driven YAML via the family's\n    #                            Fabric factory carrier.\n    # An org foundation is the canonical factory case: folder tree,\n    # folder IAM and org policies maintained as hierarchical data/ YAML\n    # via project-factory.\n    #\n    # `emission` is per scope and INHERITS: a family key you omit gets\n    # the built-in default (per-instance; additive for iam). `types:`\n    # does NOT: every scope declares its full list, and an omission is\n    # an error, not inheritance. Types decide the denominator; emission\n    # only decides how collected assets are written. Do not reason from\n    # one knob to the other.\n    #\n    # emission:\n    #   folder: factory\n    #   iam: additive     # default — google_*_iam_member per tuple; an\n    #                     # apply can never strip unmanaged members. Use\n    #                     # `authoritative` to make IAM fully declarative\n\n    types:\n      # ---------------------------------------------------------------\n      # IAM policies (pseudo-type). CAI models IAM as a content type\n      # attached to resources, not as an asset type, hence the reserved\n      # name `iam`. `levels` controls WHERE bindings are replicated:\n      #   organization -> org IAM bindings + audit configs\n      #                   (including logging_data_access)\n      #   folder       -> folder IAM bindings\n      # Project-level IAM stays unmanaged in this example (add `project`\n      # to take it over). Only container-attached IAM is covered by this\n      # pseudo-type; IAM on leaf assets (tag values, buckets, service\n      # accounts, ...) needs an `iam: true` opt-in on the leaf type's\n      # own entry.\n      # ---------------------------------------------------------------\n      - type: iam\n        levels: [organization, folder]\n\n      # ---------------------------------------------------------------\n      # Org policies (pseudo-type; CAI content type org-policy). One\n      # inventory entry per (resource, constraint) pair, so coverage is\n      # per-constraint. This example covers org-level only; declaring\n      # [organization] makes the folder-level restriction explicit and\n      # honest: folder policies are out of scope BY DECLARATION rather\n      # than silently dropped.\n      # ---------------------------------------------------------------\n      - type: org-policy\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Custom IAM roles. Fabric target: modules/organization factory,\n      # data/custom-roles/<role_id>.yaml. Org-level only; project-level\n      # custom roles would need `project` here.\n      # ---------------------------------------------------------------\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Log sinks. Fabric target: modules/organization logging_sinks\n      # locals (with iam=false; _Default/_Required are quarantined).\n      # Folder and project sinks stay unmanaged here.\n      # ---------------------------------------------------------------\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Folder hierarchy. Fabric target: one modules/folder instance per\n      # folder (stable instance keys; parent chaining by module\n      # reference). `levels` refers to the CONTAINER of the asset: a\n      # folder whose parent is the org sits at level `organization`, a\n      # nested folder at `folder`.\n      # ---------------------------------------------------------------\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n# ---------------------------------------------------------------------------\n# What this manifest deliberately does NOT cover (would have been silent\n# gaps in v1; here they are visible declarations — add entries to the\n# scope's `types:` list to take them over):\n#\n#   - type: cloudresourcemanager.googleapis.com/Project\n#     levels: [organization, folder]\n#   - type: orgpolicy.googleapis.com/CustomConstraint\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagKey\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagValue\n#     levels: [organization]\n#   - type: essentialcontacts.googleapis.com/Contact\n#     levels: [organization]\n#   - type: compute.googleapis.com/Network        # modules/net-vpc\n#   - type: iam.googleapis.com/ServiceAccount     # modules/iam-service-account\n#     levels: [project]\n#     iam: true    # ALSO collect IAM attached to each SA (impersonation\n#                  # grants). Leaf-asset IAM never enters the denominator\n#                  # without this per-type opt-in.\n# ---------------------------------------------------------------------------\n\n# ---------------------------------------------------------------------------\n# TYPES CLOUD ASSET INVENTORY DOES NOT MODEL\n#\n# A type CAI cannot see would be invisible to BOTH gates at once, so it\n# is enumerated by other means and merged into the same inventory —\n# `gcloud` first. Declare it with an `enumerate:` block in the `types:`\n# list of every scope that needs it (per-scope lists never inherit):\n#\n#   - type: iam.googleapis.com/DenyPolicy      # not in the CAI catalogue\n#     levels: [organization, folder]\n#     enumerate:\n#       command: [iam, policies, list, --kind=denypolicies]\n#       container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n#       key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n#\n# Guard rails (read-only verbs only, no --filter/--limit, unique key\n# templates), the built-in enumerator table, and the full ladder (CAI ->\n# gcloud -> REST API -> signed waiver) are in\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n","isInternal":false,"tokens":1912,"sizeBytes":9170},{"name":"waivers.example.yaml","path":"skills/fabric-importer/examples/waivers.example.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/examples/waivers.example.yaml","title":"Examples Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED waiver ledger (example).\n#\n# A waiver excludes one in-scope inventory key from the coverage gate:\n# \"we saw it, we decided not to manage it, here is why, signed\". The model\n# operating the skill MUST NOT add or edit waivers; it may only propose\n# them in its report for a human to review and commit.\n#\n# Every waiver requires key + reason. `signed_by` records who accepted\n# it — coverage.py reports waivers without it, and hard-fails them under\n# --require-signed-waivers (recommended whenever a human is in the\n# loop). Include the date in the signed_by value for auditing.\n\nwaivers:\n  - key: //cloudresourcemanager.googleapis.com/folders/000000000001\n    reason: >-\n      Decommissioning next quarter; not worth importing. Tracked in\n      TICKET-123.\n    signed_by: alice@example.com 2026-08-21\n\n  - key: //logging.googleapis.com/organizations/000000000000/sinks/tmp-debug\n    reason: temporary debugging sink owned by the SRE oncall; do not manage\n    signed_by: bob@example.com 2026-08-21\n","isInternal":false,"tokens":386,"sizeBytes":1587},{"name":"README.md","path":"skills/fabric-importer/README.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/README.md","title":"Fabric-importer Skill","category":"anthropic-skill","format":"markdown","content":"# fabric-importer\n\n`fabric-importer` reverse-engineers an existing Google Cloud organization into Cloud Foundation Fabric Terraform modules with native `import` blocks. An AI agent generates the module mapping; a set of frozen verification scripts proves the result is complete, attribute-accurate, and plan-converged. It never modifies anything in your live cloud environment.\n\nStart with the document for your role:\n\n- **Human Operator / Requesting an Import**: Follow this guide below.\n- **AI Agent Performing the Work**: Follow [SKILL.md](./SKILL.md).\n- **Supported Resources & Maturity**: See the [Maturity Matrix](./COVERAGE.md).\n\n---\n\n## The Design in One Paragraph\n\nMost infrastructure importers rely on brittle per-resource generator pipelines that quickly grow stale. This skill inverts that model: the AI agent inspects the live inventory and Fabric module sources directly, then emits idiomatic Fabric module calls paired with native Terraform 1.5+ `import {}` blocks. Two frozen verification gates validate the output. This is sound because every mistake is immediately visible: a missing attribute or schema mismatch causes `terraform plan` to propose unexpected changes, and a missed resource is flagged during inventory reconciliation. Verification per run replaces trust in generator code.\n\n---\n\n## What you provide, and what the tools work out\n\nYou write one file: `import-manifest.yaml`. Everything else is derived from it.\n\nThe important part is what that file contains. **It names resource types, not\nresources.** You never write a list of your 47 org policies. You write:\n\n```yaml\nscopes:\n  - root: organizations/123456789012\n    types:\n      - type: org-policy\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n```\n\nThat means \"in this organization, take over org policies attached at org level,\nand every folder\". Finding the actual 47 policies and 12 folders is the tool's\njob — and it has to be. If a human wrote the list of resources, \"everything is\ncovered\" would only ever mean \"everything I remembered to write down\". The\nmanifest is a deliberately coarse statement of intent; turning intent into an\nexact list is where people miss things, so a frozen script does it and refuses\nto hand back a partial answer.\n\nThe run answers five questions in order, and only the third one is yours:\n\n| Question | Answered by | Binding? |\n|---|---|---|\n| What do I already manage in Terraform? | `manifest_from_state.py` | no, advisory |\n| What actually exists in the cloud? | `inventory.py survey` + `manifest_init.py` | no, advisory |\n| **What do I want under management?** | **you, in `import-manifest.yaml`** | **yes** |\n| Which live resources does that mean, exactly? | `inventory.py collect` → `inventory.json` | yes |\n| How much of it is done? | `coverage.py` | yes |\n\nThe first two exist to break a chicken-and-egg problem: you cannot write a\nsensible manifest about an estate you cannot see, and the tools that measure the\nestate need a manifest first. Neither is a gate, neither produces anything\nbinding, and you can skip both if you already know your scope. Run them anyway\nthe first time: the gap between \"what Terraform manages\" and \"what exists\" is\nusually the most useful thing discovered all week.\n\n### Different depth per subtree: per-scope `types:`\n\nOne estate rarely wants the same depth everywhere — governance at org and\nfolder level, the full connectivity stack in the networking folder, two named\nworkload projects and nothing else. `scopes:` is a list, and **every scope\ncarries its own `types:` list**; there is no other place to declare types, so\nwhat a scope collects is exactly what is written on it:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: org-policy\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: workloads\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111\n    types:\n      - type: iam\n        levels: [project]\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n```\n\nThe same type may appear in several scopes with different `levels`, `iam` or\n`enumerate`. Because a type list decides the denominator, the rules fail the\nrun with a message rather than warning — before any API call, exit 1:\n\n| Rule | Why |\n|---|---|\n| Every scope declares `types:`; `types: []` is rejected | A scope that collects nothing passes every check by not collecting |\n| A type whose `levels` cannot intersect its scope's `levels` is rejected | A dead declaration reads like coverage and produces none (an entry listing `unknown` is exempt) |\n| A type declared twice in one list is rejected | Last-wins would quietly narrow levels |\n| The retired grammar (top-level `scope:` / `types:`) is rejected with migration instructions | Half-accepting it would change what the denominator means — see [references/manifest-migration.md](./references/manifest-migration.md) |\n\n`examples/import-manifest.multi-domain.yaml` is a worked four-scope manifest;\n`examples/import-manifest.org-foundation.yaml` is the single-scope reference.\n\nAfterwards, read the **per-scope** tables in `inventory.json`:\n`_meta.scopes[].declared_types` and `_meta.scopes[].zero_yield_types`.\n`_meta.declared_types` is the aggregate across scopes, and the aggregate can\nhide a scope whose own declaration yielded nothing — a type non-zero org-wide\nand zero in the one scope you cared about. Every inventory entry (and\ntherefore every worklist entry) also names the scope(s) that collected it in\nits `scopes` field.\n\n### `survey` and `collect` are not the same thing\n\nThey are the same script and both list resources, which makes them easy to\nconfuse. Their purposes are opposite.\n\n`survey` runs **without** a manifest. It browses, so that a human can decide. Its\noutput is a conversation aid and is never used to judge anything.\n\n`collect` runs **only** with an approved manifest. Its output is the\n**denominator**: the exact list of live resources matching what you agreed.\nEverything downstream is measured against it.\n\n### The two gates answer different questions\n\nThis is the part worth reading twice, because neither gate can catch the other's\nfailure.\n\n| Gate | Compares | Answers |\n|---|---|---|\n| 1 — `coverage.py` | `inventory.json` against the **text of your code** | did you cover everything? |\n| 2 — `verify_plan.py` | the **Terraform plan** against reality | is what you wrote correct? |\n\nGate 1 never runs Terraform and never contacts your cloud. It reads\n`inventory.json`, the `import {}` blocks it parses out of your `*.tf` files, the\n`coverage-map.yaml` ledger, and `waivers.yaml`. Every asset in the denominator\nmust be in the ledger or in the waivers.\n\nGate 1 can pass on completely wrong code — right addresses, garbage\nconfiguration. The plan catches that, as diffs.\n\nGate 2 can pass on a workspace covering 3 of your 59 resources. A plan of three\nclean imports and nothing else is perfectly converged; Terraform has no idea the\nother 56 exist. **A converged plan says nothing about completeness**, because\nTerraform only knows about what you wrote down. Completeness can only be judged\nagainst a list built independently of the code, which is exactly what\n`inventory.json` is.\n\n### `inventory.json` is the only door\n\nThe loop is closed in both directions:\n\n- a ledger key that is not in the inventory fails gate 1 as `stale`\n- an `import {}` block that no ledger key claims fails gate 1 as an `orphan`\n\nSo nothing can be imported that the denominator does not contain, and nothing in\nthe denominator can be quietly dropped. That is what makes \"both gates green\"\nmean something precise: everything the manifest declared is either in code or\nwaived by name, and applying that code would change nothing.\n\n---\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where human judgment is required (e.g. narrowing scope, waiving assets, or accepting provider drift). Keep mechanical, reversible steps (asset collection, scaffolding, linting, plan checks) autonomous. Gates are **blocking**: if running non-interactively and confirmation cannot be obtained, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Scope Approval** | Manifest drafting, before any enumeration | Reviews and commits `import-manifest.yaml`: which resource types each scope declares, at which hierarchy levels (org/folder/project), under which included/excluded subtrees. |\n| **Waiver Signing** | Completeness Gate (`coverage.py`) | Reviews deliberate exclusions in `waivers.yaml` and signs them with attribution (`signed_by`) for unmanaged or auto-generated resources (e.g. default log sinks, default compute service accounts). |\n| **Benign Drift Review** | Plan Convergence Gate (`verify_plan.py`) | Evaluates proposed cosmetic provider quirks (e.g. computed labels, default timeouts) and commits reviewed entries to `scripts/benign-drift.yaml`. |\n| **Final Review & Apply** | Handover, before `terraform apply` | Inspects the final zero-drift plan, run report, and input provenance digests before running `terraform apply` on their own schedule. |\n\n---\n\n## How the work flows\n\nThree views. The main loop, then the two places where a single item funnels\nthrough a decision — enumeration and mapping.\n\n### The main loop\n\n```mermaid\nflowchart TD\n    A[\"Existing .tfstate files<br/>or the live estate\"] --> B[\"Draft import-manifest.yaml<br/>manifest_from_state.py, or<br/>inventory.py survey + manifest_init.py\"]\n    B --> G0{{\"HUMAN GATE 1<br/>approve the scope\"}}\n    G0 --> C[\"inventory.py collect\"]\n    C --> D[(\"inventory.json<br/>THE DENOMINATOR\")]\n    D --> E[\"coverage.py --worklist-out\"]\n    E --> F[\"AGENT WORK<br/>module config + import blocks<br/>+ coverage-map.yaml\"]\n    F --> G1{\"GATE 1 machine<br/>coverage.py\"}\n    G1 -- \"missing assets\" --> W{{\"HUMAN GATE 2<br/>sign a waiver\"}}\n    W --> G1\n    G1 -- \"gaps to close\" --> F\n    G1 -- \"reconciled, exit 0\" --> P[\"terraform plan\"]\n    P --> G2{\"GATE 2 machine<br/>verify_plan.py\"}\n    G2 -- \"residual diff\" --> F\n    G2 -- \"provider quirk?\" --> BD{{\"HUMAN GATE 3<br/>accept benign drift\"}}\n    BD --> G2\n    G2 -- \"converged, exit 0\" --> R[\"Run report\"]\n    R --> NEXT[\"Widen the manifest<br/>for the next round\"]\n    NEXT --> G0\n    R --> G3{{\"HUMAN GATE 4<br/>apply sign-off\"}}\n    G3 --> DONE([\"terraform apply<br/>run by the operator\"])\n```\n\nEverything above the scope gate is advisory. Everything below it is mechanical\nand binding.\n\n### Funnel 1: how a declared type becomes inventory entries\n\n```mermaid\nflowchart TD\n    T[\"One type declared in a<br/>scope's types: list\"] --> Q1{\"pseudo-type?\"}\n    Q1 -- \"iam\" --> S1[\"asset list<br/>--content-type=iam-policy\"]\n    Q1 -- \"org-policy\" --> S2[\"three streams merged:<br/>org-policy content type<br/>+ orgpolicy Policy assets<br/>+ gcloud org-policies list<br/>per container\"]\n    Q1 -- \"no, a real asset type\" --> Q2{\"enumerator registered?<br/>built-in table, or a<br/>manifest enumerate: block\"}\n    Q2 -- \"yes\" --> S3[\"gcloud list per container<br/>CAI never consulted\"]\n    Q2 -- \"no\" --> S4[\"gcloud asset list<br/>batched with the other types\"]\n    S4 --> Q3{\"outcome\"}\n    Q3 -- \"ok\" --> OUT\n    Q3 -- \"type not in the<br/>CAI catalogue\" --> STOP[\"STOP, exit 3<br/>fix the type string,<br/>add an enumerator,<br/>or drop it deliberately\"]\n    Q3 -- \"any other error<br/>403, ACM types\" --> S5[\"asset search-all-resources\"]\n    S5 -- \"ok\" --> OUT\n    S5 -- \"still failing\" --> STOP2[\"STOP, exit 3<br/>partial denominator refused\"]\n    S1 --> OUT[(\"inventory.json\")]\n    S2 --> OUT\n    S3 --> OUT\n```\n\nNo path exits with \"skip it\". Every branch ends in the denominator or in a stop,\nbecause an unenumerated asset is invisible to both gates at once — it would pass\nevery check by not existing. The funnel runs once per scope: the same type can\nenter it from two scopes' lists with different levels, and a type declared in\nonly one scope is swept only there.\n\n### Funnel 2: how a worklist item becomes code\n\n```mermaid\nflowchart TD\n    I[\"Worklist item\"] --> M{\"Does the canonical<br/>Fabric module express<br/>the live resource?\"}\n    M -- \"yes\" --> A[\"Module config<br/>+ import block<br/>+ coverage-map entry\"]\n    M -- \"module exists but cannot<br/>express an attribute\" --> B[\"Raw google_* resource<br/>+ MODULE CAPABILITY GAP<br/>in the report\"]\n    M -- \"no module covers<br/>the type at all\" --> C[\"Raw google_* resource<br/>+ report, propose upstream\"]\n    M -- \"deliberately out of scope\" --> D{{\"HUMAN GATE<br/>signed waiver\"}}\n    M -- \"a residual you<br/>cannot explain\" --> STOP[\"STOP<br/>report it, never rationalise it\"]\n    A --> P[\"Both gates\"]\n    B --> P\n    C --> P\n    D --> X[\"Not in the workspace,<br/>named in the report\"]\n```\n\nThree of those four outcomes are successes. That matters: the temptation at the\nmiddle two is to force the module to fit, or to call the leftover diff benign.\n\n---\n## Step-by-Step Operator Guide\n\n### 1. Declare the Scope (`import-manifest.yaml`)\nYou and the agent agree on an `import-manifest.yaml` declaring, per scope, which resource types to import (e.g., folders, service accounts, VPC networks, org policies), at which container levels (organization, folder, or project), and which subtrees to include or exclude — see [Different depth per subtree](#different-depth-per-subtree-per-scope-types).\n\nThis is the only file you author, and it names types, not resources. The two\noptions below are drafting aids that answer different questions — A tells you\nwhat Terraform already manages, B tells you what exists. Run both the first\ntime and compare them; run neither if you already know your scope. Neither\noutput is binding until you have reviewed it and committed it.\n\n**Option A — Inferred from existing Terraform state(s):**\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n```\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate:**\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json --scope organizations/ORG_ID --out import-manifest.yaml\n```\nReady-made examples aligned with FAST stages are provided in\n[`examples/`](./examples/); `import-manifest.org-foundation.yaml` is the\nsingle-scope reference and `import-manifest.multi-domain.yaml` the per-scope\nmulti-domain one.\n\n### 2. Enumerate the Denominator (`inventory.json`)\nThe enumeration script queries Cloud Asset Inventory (CAI) and service APIs to construct the exact denominator of all matching live assets:\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nCAI is the default source of the denominator, not its boundary. CAI does\nnot model every GCP resource, and a type it cannot see would otherwise be\ninvisible to both gates at once — so the tool routes around it rather\nthan shrinking the denominator: built-in `gcloud` enumerators for types\nknown to be absent from the catalogue (declaring the type is all it\ntakes), automatic sibling sweeps for the Compute families whose primary\nCAI surface splits them into separate scoped types (accounted under the\ndeclared type, real type preserved in `_meta.split_type_sweeps`), and a\nhard stop with the remedy where no enumerator exists — either the type\nstring is wrong (checked against the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types)),\nor the manifest needs a native `enumerate:` block: a read-only `gcloud`\ncommand run per in-scope container, normalized into the same inventory,\ndeclared in the `types:` list of each scope that needs it (per-scope\nlists never inherit). The full ladder (CAI → gcloud → REST API → signed\nwaiver), the enumerator syntax and guard rails, and the split-taxonomy\nmechanics are in\n[references/cai-blind-spots.md](./references/cai-blind-spots.md).\n\nThe split-type table is a frozen snapshot of a document Google changes,\nso check it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nIt costs one extra `search-all-resources` call per scope and fails the\nrun if the search surface returns an asset the list sweep did not.\n\nThe run stays auditable without becoming unreadable: every run closes\nwith a one-line cost summary on stderr (`7 gcloud call(s) in 12.0s:\nasset list x5, org-policies list x2`), `--verbose` (after the\nsubcommand) prints each command as it runs with outcome, duration and\nitem count, the full log is written to `_meta.api_calls`, and every\nnon-CAI sweep is recorded verbatim in `_meta.native_sweeps` so a\nreviewer can re-run it.\n\nProvenance is per scope: each `_meta.scopes[]` entry records its own\n`declared_types` yield counts and `zero_yield_types`, and every\ninventory entry names the scope(s) that collected it.\n`_meta.declared_types` is the aggregate — read the per-scope counts\ntoo, because a type that yields zero in one scope and non-zero in\nanother does not show up in the aggregate zero-yield warning (the\nper-scope warning on stderr does).\n\n### 3. Compute Delta Worklist\nThe completeness tool reconciles the denominator against any existing code and written waivers. This is the same program that runs as gate 1 in step 5 — the same comparison, run for a different reason. Here it tells you what is left to do; there it decides whether anything is. On the first round everything is outstanding, so the worklist is the whole denominator. On later rounds it is only the delta, which is what makes repeated rounds cheap:\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\n### 4. Agent Maps & Emits Terraform\nFor each item in `worklist.yaml`, the agent writes:\n1. Canonical Fabric module configuration matching the live state.\n2. A paired Terraform `import {}` block:\n   ```hcl\n   import {\n     to = module.folder-team-a.google_folder.folder[0]\n     id = \"folders/123456789012\"\n   }\n   ```\n3. A ledger entry in `coverage-map.yaml` linking the asset to its Terraform address.\n\n### 5. Automated Verification (Definition of Done)\nWork is complete only when two independent gates pass with exit code `0`. They are independent in a specific sense: each catches a failure the other cannot see. Gate 1 can pass on wrong code, and gate 2 can pass on a workspace covering three resources out of fifty-nine. See [The two gates answer different questions](#the-two-gates-answer-different-questions).\n- **Completeness Gate (`coverage.py`)**: Reconciles the inventory against `coverage-map.yaml`, `waivers.yaml`, and emitted `import {}` blocks. Every asset must be mapped or waived. It reads files only — no Terraform, no cloud access — so it needs neither credentials nor a `terraform init` to run.\n- **Plan Convergence Gate (`verify_plan.py`)**: Consumes `terraform show -json <planfile>` output (file argument or stdin) and classifies the planned actions. It never invokes Terraform itself. The plan may only contain clean imports, no-ops, and human-reviewed benign drift entries from `scripts/benign-drift.yaml`. Any residual attribute modification or destruction fails the gate.\n\nRun both gates like this:\n\n```bash\nterraform -chdir=tf fmt -recursive\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # a stale plan must never answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` to the plan: it returns 2 for any\nnon-empty plan, and a converged import plan is always non-empty.\n`verify_plan.py` is the verdict.\n\n### 6. Review and Apply\nThe resulting workspace (`tf/`) is clean, formatted, and ready for you to review and run `terraform apply` at your own pace. The skill **never** runs `terraform apply` or mutates live cloud resources.\n\n---\n\n## Script reference\n\nRun every script from the skill directory. All output paths are relative\nto the current working directory.\n\n| Script | Purpose | Flags |\n|---|---|---|\n| `inventory.py survey` | Enumerate everything in scope, to draft a manifest from | `--scope` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants` |\n| `manifest_init.py` | Draft a manifest from a survey (Mode B) | `--survey` (required), `--scope` (required), `--out` |\n| `manifest_from_state.py` | Infer a manifest from existing `.tfstate` (Mode A) | `--state` (1+, required), `--out` (`-` for stdout), `--force` to overwrite an existing manifest |\n| `inventory.py collect` | Build the denominator from CAI | `--manifest` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants`, `--verify-search-parity` |\n| `coverage.py` | Gate 1 — completeness | `--inventory` (required), `--workspace` (required), `--coverage-map`, `--waivers`, `--require-signed-waivers`, `--allow-empty-inventory`, `--worklist-out` |\n| `verify_plan.py` | Gate 2 — plan convergence | positional plan JSON (default stdin), `--rules`, `--allow-empty-plan` |\n| `integrity.py` | Print the frozen-tools provenance digest | `--verbose` for per-file digests |\n\n`inventory.py` requires the `survey` or `collect` subcommand; it has no\ntop-level flags. `--verbose` therefore goes after the subcommand\n(`inventory.py collect --manifest m.yaml --verbose`), not before it.\n\nExit codes — `coverage.py`: `0` reconciled, `1` malformed input, `2` gaps\nor problems. `verify_plan.py`: `0` converged, `1` malformed input, `2`\nresidual changes, `3` converged but ADVISORY (a substituted `--rules`\nfile). A substituted ruleset can never exit `0`; a residual plan still\nexits `2`. `inventory.py`: `0` ok, `1` a manifest the validator refuses\n(retired grammar, missing/empty per-scope `types:`, duplicate type,\ninvalid level, dead per-scope declaration — printed with the reason\nbefore any API call), `3` a partial denominator (unsupported type,\ntolerated sweep failure, split-parity gap). Note that argparse usage\nerrors also exit `2`, so read the message, not only the code.\n\nThere is no checked-in expected digest. To check a recorded gate run,\ncompute `uv run scripts/integrity.py` from a pristine checkout of the\nsame commit and compare it with the `frozen tools:` line in the captured\noutput.\n\n---\n\n## Prerequisites & Permissions\n\n### System Requirements\n- **Terraform**: `v1.5.0+` (required for native `import {}` blocks).\n- **Google Cloud SDK (`gcloud`)**: Authenticated with a configured quota project.\n- **[`uv`](https://docs.astral.sh/uv/)**: the recommended way to run the scripts.\n  Every script declares its own dependencies inline\n  ([PEP 723](https://peps.python.org/pep-0723/), the same convention as\n  `tools/tfdoc.py`), so `uv run scripts/<name>.py` resolves them per script with\n  nothing to install and nothing to activate.\n- **Python**: `3.10+` if you would rather not use `uv`. Then run the scripts with\n  `python3` and install `PyYAML` yourself; the commands are otherwise identical.\n\n### Minimal Read-Only IAM Permissions\nRun the import using a dedicated read-only identity. The following roles on the organization scope provide full read visibility:\n\n| Role | Purpose |\n|---|---|\n| `roles/cloudasset.viewer` | CAI asset inventory enumeration across the hierarchy |\n| `roles/resourcemanager.organizationViewer` | Organization resource details |\n| `roles/resourcemanager.folderViewer` | Folder hierarchy discovery |\n| `roles/viewer` | Read-only inspection of imported resources via service APIs |\n| `roles/iam.securityReviewer` | IAM policy discovery at organization, folder, and project levels |\n| `roles/orgpolicy.policyViewer` | Organization policy evaluation |\n| `roles/accesscontextmanager.policyReader` | VPC Service Controls discovery (when VPC-SC is in scope) |\n\nNo write or admin permissions are required.\n\n---\n\n## User Ownership Boundary\n\nThree human-owned files govern the process. The agent may propose edits, but only you commit them:\n\n| File | Purpose |\n|---|---|\n| `import-manifest.yaml` | Declares the scopes, each with its own resource-type list, container levels, and subtree filters. |\n| `waivers.yaml` | Written waivers for resources deliberately excluded from management (e.g. `_Default` log sinks, default compute service accounts). Requires a `signed_by` attribute. |\n| `scripts/benign-drift.yaml` | Scoped provider quirks accepted as cosmetic diffs (e.g., computed provider labels or default timeouts). |\n| `references/address-map.yaml` | The fielded subset of the mapping cookbook: asset type, module, address pattern, import ID, verification round. Advisory, not a gate input — see below. |\n\n---\n\n## Repository Layout\n\n```text\nskills/fabric-importer/\n├── SKILL.md                     # Agent protocol and operational contract\n├── README.md                    # Human operator guide and architecture overview\n├── COVERAGE.md                  # Resource family maturity matrix (N / C / V)\n├── scripts/                     # Verification gates and helper utilities\n│   ├── inventory.py             #   Asset enumeration (CAI + API sweeps)\n│   ├── coverage.py              #   Completeness gate & delta worklist generator\n│   ├── verify_plan.py           #   Plan-convergence gate (benign-drift aware)\n│   ├── manifest_from_state.py   #   Terraform state-driven manifest inference\n│   ├── manifest_init.py         #   Starter manifest drafting assistant\n│   ├── integrity.py             #   Input binding & runtime provenance stamping\n│   ├── address_map.py           #   Address-map validator, cookbook linter, table renderer\n│   └── benign-drift.yaml        #   Human-reviewed provider quirk ruleset\n├── examples/                    # Manifest and waiver templates\n│   ├── import-manifest.org-foundation.yaml\n│   ├── import-manifest.multi-domain.yaml\n│   ├── import-manifest.fast-org-setup.yaml\n│   ├── import-manifest.fast-security.yaml\n│   ├── import-manifest.fast-vpcsc.yaml\n│   ├── import-manifest.fast-networking.yaml\n│   └── waivers.example.yaml\n├── references/                  # Normative mapping rules & technical gotchas\n│   ├── mapping-cookbook.md      #   Traps, reasoning, capability gaps (prose)\n│   ├── address-map.yaml         #   Addresses, import IDs, asset types (data)\n│   ├── inferring-manifests-from-state.md # State-driven manifest inference workflow\n│   ├── manifest-migration.md    #   Migrating manifests from the retired grammar\n│   ├── cai-blind-spots.md       #   Known CAI gaps and service API mitigations\n│   └── operating-contract.md    #   Safety invariants and trust boundaries\n└── tests/                       # Unit test suite\n```\n\n### Where mapping knowledge lives\n\nThe cookbook carries two different kinds of knowledge, and they are now\nstored differently because they fail differently.\n\n**Fielded tuples** — asset type, module, address pattern, import ID,\nverification round — live in `references/address-map.yaml`. They are\nuniform and mechanically checkable, and they were the part that kept\nbeing duplicated: the same address appeared in per-type prose and again\nin a hand-maintained markdown table, and three families were once\ndocumented twice because nothing could detect it. Two invariants are now\nenforced:\n\n- an address pattern is claimed by exactly one entry;\n- a CAI asset type serviced by more than one module (the live case:\n  `compute.googleapis.com/Router`, created by both `net-cloudnat` and\n  `net-vpn-ha`) must say how to tell the carriers apart.\n\n**Reasoning** — ForceNew traps, provider quirks, waiver postures,\ncapability gaps — stays in `references/mapping-cookbook.md`, in prose,\non purpose. It is causal and only degrades when squeezed into a schema.\n\n```bash\nuv run scripts/address_map.py                      # validate + lint\nuv run scripts/address_map.py --render-cookbook    # regenerate the table\nuv run scripts/address_map.py --check-workspace tf/  # advisory, always exits 0\n```\n\n`address_map.py` is a linter, **not a gate**. `coverage.py` and\n`verify_plan.py` never read the address map, and a test enforces that.\nA wrong entry is caught by `terraform plan` erroring loudly, unlike a\nwrong `benign-drift.yaml` entry which would silently pass a gate. The\nmap is also deliberately incomplete: an address it does not recognise is\na type nobody has mapped yet, which SKILL.md calls the normal case.\n","isInternal":false,"tokens":6910,"sizeBytes":29117},{"name":"address-map.yaml","path":"skills/fabric-importer/references/address-map.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/address-map.yaml","title":"References Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Address and import-ID map — the FIELDED subset of the mapping cookbook.\n#\n# WHY THIS FILE EXISTS\n#\n# `mapping-cookbook.md` carries two different kinds of knowledge:\n#\n#   1. Fielded tuples — (asset type, module, address pattern, import ID,\n#      verification round). Uniform across every entry, mechanically\n#      checkable, and the part that kept being duplicated: the same\n#      address appeared once in the per-type prose and again in a\n#      hand-maintained markdown table, and three families were added\n#      twice because nothing could detect it.\n#\n#   2. Reasoning — ForceNew traps, provider quirks, waiver postures,\n#      capability gaps. Causal prose that becomes strictly worse when\n#      squeezed into a `notes:` string.\n#\n# Only (1) lives here. (2) stays in the cookbook, in prose, on purpose.\n# This file is NOT a specification of what the skill supports and NOT a\n# closed world: a type absent from it is a type nobody has run the\n# method on yet. See \"Step 3b\" in SKILL.md. Never treat a miss here as\n# a reason to stop, and never treat a hit here as a substitute for\n# reading the module source.\n#\n# NOT FROZEN. This file is advisory input to `address_map.py`, which is\n# a linter, not a gate. It is deliberately outside the trust boundary in\n# `integrity.py`: a wrong entry here makes `terraform plan` error\n# loudly, unlike a wrong `benign-drift.yaml` entry which silently passes\n# a gate. Different blast radius, different rigour budget.\n#\n# SCHEMA (enforced by `scripts/address_map.py --validate`)\n#\n#   version: 1\n#   entries:\n#     <slug>:                # stable, kebab-case, immutable once used\n#       resource:      str   # human label, used for the rendered table\n#       module:        str   # 'modules/<name>', or 'raw' for a gap\n#       address:       str   # Terraform address pattern, <placeholder>s\n#       import_id:     str   # provider import ID pattern\n#       asset_type:    str   # CAI asset type, or null when not CAI-modelled\n#       verified:      str   # 'r<N>' round that proved it, or false\n#       note:          str   # optional, ONE line; prose belongs in the cookbook\n#       disambiguation: str  # REQUIRED when an asset_type maps to >1 module\n#\n# INVARIANTS (all machine-enforced)\n#   - `address` is globally unique: no two entries may claim it.\n#   - when one `asset_type` maps to more than one `module`, EVERY entry\n#     sharing that type must carry a non-empty `disambiguation`.\n#   - `verified` is false or matches `r<digits>`.\n#   - `note` is a single line (multi-line reasoning goes in the cookbook).\n#\n# Regenerate the cookbook's rendered table after editing:\n#   uv run scripts/address_map.py --render-cookbook\n\nversion: 1\n\nentries:\n\n  # --- organization -------------------------------------------------\n\n  org-custom-role:\n    resource: Custom role\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_custom_role.roles[\"<id>\"]'\n    import_id: 'organizations/<org>/roles/<id>'\n    asset_type: iam.googleapis.com/Role\n    verified: r3\n\n  org-iam-member:\n    resource: Org IAM member (additive, default)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_member.bindings[\"<key>\"]'\n    import_id: '<org> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  org-iam-binding-authoritative:\n    resource: Org IAM binding (authoritative opt-in)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<org> <role>'\n    asset_type: null\n    verified: r3\n\n  org-iam-binding-conditional:\n    resource: Org IAM binding (cond.)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.bindings[\"<key>\"]'\n    import_id: '<org> <role> <condition_title>'\n    asset_type: null\n    verified: r3\n\n  org-audit-config:\n    resource: Org audit config\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_audit_config.default[\"<service>\"]'\n    import_id: '<org> <service>'\n    asset_type: null\n    verified: r3\n\n  org-policy:\n    resource: Org policy\n    module: modules/organization\n    address: 'module.organization.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'organizations/<org>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r5\n    disambiguation: 'org-level constraints; folder and project scopes have their own entries'\n\n  org-log-sink:\n    resource: Org log sink\n    module: modules/organization\n    address: 'module.organization.google_logging_organization_sink.sink[\"<name>\"]'\n    import_id: 'organizations/<org>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r3\n    disambiguation: 'org-level sinks; folder sinks are carried by modules/folder'\n\n  org-tag-key:\n    resource: Tag Key\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_key.default[\"<short_name>\"]'\n    import_id: 'tagKeys/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagKey\n    verified: r18\n\n  org-tag-value:\n    resource: Tag Value\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]'\n    import_id: 'tagValues/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagValue\n    verified: r18\n\n  tag-binding:\n    resource: Tag Binding\n    module: modules/organization\n    address: 'module.<instance>.google_tags_tag_binding.binding[\"<key>\"]'\n    import_id: 'tagBindings/<url-encoded-parent-and-value>'\n    asset_type: cloudresourcemanager.googleapis.com/TagBinding\n    verified: r18\n    note: 'emitted on the container module that owns the bound resource'\n\n  # --- folders ------------------------------------------------------\n\n  folder:\n    resource: Folder\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder.folder[0]'\n    import_id: 'folders/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/Folder\n    verified: r6\n\n  folder-iam-member:\n    resource: Folder IAM member (additive, default)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  folder-iam-binding-authoritative:\n    resource: Folder IAM\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'folders/<id> <role>'\n    asset_type: null\n    verified: r6\n\n  folder-iam-binding-conditional:\n    resource: Folder IAM (cond.)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <condition_title>'\n    asset_type: null\n    verified: r6\n\n  folder-org-policy:\n    resource: Folder org policy\n    module: modules/folder\n    address: 'module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'folders/<id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r6\n    disambiguation: 'folder-scoped constraints; org and project scopes have their own entries'\n\n  folder-log-sink:\n    resource: Folder sink\n    module: modules/folder\n    address: 'module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]'\n    import_id: 'folders/<id>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r6\n    disambiguation: 'folder-level sinks; org sinks are carried by modules/organization'\n\n  # --- projects -----------------------------------------------------\n\n  project:\n    resource: Project\n    module: modules/project\n    address: 'module.project-<key>.google_project.project[0]'\n    import_id: 'projects/<project_id>'\n    asset_type: cloudresourcemanager.googleapis.com/Project\n    verified: r4\n\n  project-service:\n    resource: Project service\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.project_services[\"<api>\"]'\n    import_id: '<project_id>/<api>'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-service-orgpolicy:\n    resource: Project service (orgpolicy)\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.org_policy_service[0]'\n    import_id: '<project_id>/orgpolicy.googleapis.com'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-iam-member:\n    resource: Project IAM member (additive, default)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_member.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  project-iam-binding-authoritative:\n    resource: Project IAM\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<project_id> <role>'\n    asset_type: null\n    verified: r4\n\n  project-iam-binding-conditional:\n    resource: Project IAM (cond.)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <condition_title>'\n    asset_type: null\n    verified: r4\n\n  project-org-policy:\n    resource: Project org policy\n    module: modules/project\n    address: 'module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'projects/<project_id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: false\n    disambiguation: 'project-scoped constraints; org and folder scopes have their own entries'\n\n  workload-identity-pool:\n    resource: Workload Identity Pool\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPool\n    verified: r18\n\n  workload-identity-pool-provider:\n    resource: Workload Identity Pool Provider\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPoolProvider\n    verified: r18\n\n  pam-entitlement:\n    resource: PAM entitlement\n    module: modules/project\n    address: 'module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]'\n    import_id: '<parent>/locations/<location>/entitlements/<entitlement_id>'\n    asset_type: privilegedaccessmanager.googleapis.com/Entitlement\n    verified: false\n    note: '<parent> is organizations/<org>, folders/<id> or projects/<project_id>; grants are excluded, never imported'\n\n  # --- service accounts ---------------------------------------------\n\n  service-account:\n    resource: Service account\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account.service_account[0]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email>'\n    asset_type: iam.googleapis.com/ServiceAccount\n    verified: r8\n\n  service-account-iam-member:\n    resource: SA IAM member (additive, default)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  service-account-iam-binding-authoritative:\n    resource: SA IAM binding\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role>'\n    asset_type: null\n    verified: r8\n\n  service-account-iam-binding-conditional:\n    resource: SA IAM binding (cond.)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <condition_title>'\n    asset_type: null\n    verified: r8\n\n  # --- logging and storage ------------------------------------------\n\n  log-bucket:\n    resource: Log bucket\n    module: modules/logging-bucket\n    address: 'module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]'\n    import_id: 'projects/<project_id>/locations/<location>/buckets/<name>'\n    asset_type: logging.googleapis.com/LogBucket\n    verified: r10\n\n  gcs-bucket:\n    resource: GCS bucket\n    module: modules/gcs\n    address: 'module.<instance>.google_storage_bucket.bucket[0]'\n    import_id: '<project_id>/<bucket_name>'\n    asset_type: storage.googleapis.com/Bucket\n    verified: r12\n    note: 'never import bare <bucket_name> — the project qualifier is a ForceNew trap'\n\n  bigquery-dataset:\n    resource: BigQuery Dataset\n    module: modules/bigquery-dataset\n    address: 'module.<instance>.google_bigquery_dataset.default'\n    import_id: 'projects/<project_id>/datasets/<dataset_id>'\n    asset_type: bigquery.googleapis.com/Dataset\n    verified: r17\n\n  pubsub-topic:\n    resource: Pub/Sub Topic\n    module: modules/pubsub\n    address: 'module.<instance>.google_pubsub_topic.default'\n    import_id: 'projects/<project_id>/topics/<topic_name>'\n    asset_type: pubsub.googleapis.com/Topic\n    verified: r17\n\n  # --- VPC networking -----------------------------------------------\n\n  vpc-network:\n    resource: VPC network\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_network.network[0]'\n    import_id: 'projects/<project_id>/global/networks/<name>'\n    asset_type: compute.googleapis.com/Network\n    verified: r3\n\n  vpc-subnet:\n    resource: VPC subnet\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r3\n    disambiguation: 'ordinary subnets; proxy-only and private-NAT subnets have their own entries'\n\n  vpc-subnet-proxy-only:\n    resource: VPC subnet (proxy-only)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r9\n    disambiguation: 'purpose REGIONAL_MANAGED_PROXY or GLOBAL_MANAGED_PROXY on the live subnet'\n\n  vpc-subnet-private-nat:\n    resource: VPC subnet (private NAT)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: false\n    disambiguation: 'purpose PRIVATE_NAT on the live subnet'\n\n  vpc-route-gateway:\n    resource: VPC route (gateway)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_gateway on the live route'\n\n  vpc-route-ilb:\n    resource: VPC route (ilb)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ilb on the live route'\n\n  vpc-route-instance:\n    resource: VPC route (instance)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_instance on the live route'\n\n  vpc-route-ip:\n    resource: VPC route (ip)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ip on the live route'\n\n  vpc-route-vpn-tunnel:\n    resource: VPC route (vpn tunnel)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_vpn_tunnel on the live route'\n\n  # --- VPC firewall rules (legacy) ----------------------------------\n\n  vpc-firewall-custom-rule:\n    resource: VPC firewall rule (custom)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/global/firewalls/<rule_name>'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'every rule not generated by the module default-rule toggles'\n\n  vpc-firewall-default-admins:\n    resource: VPC firewall rule (default, admins)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_admins[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-admins'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.admin_ranges, not by custom_rules'\n\n  vpc-firewall-default-http:\n    resource: VPC firewall rule (default, http)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_http[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.http_ranges, not by custom_rules'\n\n  vpc-firewall-default-https:\n    resource: VPC firewall rule (default, https)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_https[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.https_ranges, not by custom_rules'\n\n  vpc-firewall-default-ssh:\n    resource: VPC firewall rule (default, ssh)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_ssh[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.ssh_ranges, not by custom_rules'\n\n  # --- firewall policies --------------------------------------------\n\n  firewall-policy-hierarchical:\n    resource: Hierarchical Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_firewall_policy.hierarchical[0]'\n    import_id: 'locations/global/firewallPolicies/<id>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'org- or folder-attached policy'\n\n  firewall-policy-network-global:\n    resource: Global Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_network_firewall_policy.net_global[0]'\n    import_id: 'projects/<project_id>/global/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, global'\n\n  firewall-policy-network-regional:\n    resource: Regional Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]'\n    import_id: 'projects/<project_id>/regions/<region>/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, regional'\n\n  # --- compute addresses --------------------------------------------\n\n  address-global-psc:\n    resource: Compute address (global PSC)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psc[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'addressType INTERNAL with purpose PRIVATE_SERVICE_CONNECT'\n\n  address-global-psa:\n    resource: Compute address (global PSA)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psa[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose VPC_PEERING'\n\n  address-regional-internal:\n    resource: Compute address (regional internal)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_address.internal[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose GCE_ENDPOINT or SHARED_LOADBALANCER_VIP'\n\n  # --- routers, NAT, VPN, interconnect ------------------------------\n\n  cloud-router:\n    resource: Cloud Router\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'router whose only attached function is Cloud NAT; a router that also terminates VPN tunnels belongs to modules/net-vpn-ha'\n\n  cloud-nat:\n    resource: Cloud NAT\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router_nat.nat'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router>/<nat>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-ha-gateway:\n    resource: HA VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnGateways/<name>'\n    asset_type: compute.googleapis.com/VpnGateway\n    verified: false\n\n  vpn-external-gateway:\n    resource: External VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]'\n    import_id: 'projects/<project_id>/global/externalVpnGateways/<name>'\n    asset_type: compute.googleapis.com/ExternalVpnGateway\n    verified: false\n\n  vpn-tunnel:\n    resource: VPN Tunnel\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnTunnels/<name>'\n    asset_type: compute.googleapis.com/VpnTunnel\n    verified: false\n    note: 'shared_secret is ForceNew and unreadable — see the cookbook section before emitting'\n\n  vpn-router:\n    resource: Cloud Router (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'router that terminates VPN tunnels; a NAT-only router belongs to modules/net-cloudnat'\n\n  vpn-router-interface:\n    resource: Cloud Router interface (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]'\n    import_id: '<project_id>/<region>/<router_name>/<interface_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-router-peer:\n    resource: Cloud Router BGP peer (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  interconnect-attachment:\n    resource: Interconnect Attachment\n    module: modules/net-vlan-attachment\n    address: 'module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/interconnectAttachments/<name>'\n    asset_type: compute.googleapis.com/InterconnectAttachment\n    verified: false\n\n  # --- Cloud DNS ----------------------------------------------------\n\n  dns-managed-zone:\n    resource: DNS zone\n    module: modules/dns\n    address: 'module.<instance>.google_dns_managed_zone.dns_managed_zone[0]'\n    import_id: 'projects/<project_id>/managedZones/<zone>'\n    asset_type: dns.googleapis.com/ManagedZone\n    verified: r12\n\n  dns-record-set:\n    resource: DNS recordset\n    module: modules/dns\n    address: 'module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]'\n    import_id: 'projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>'\n    asset_type: dns.googleapis.com/ResourceRecordSet\n    verified: false\n    note: 'for_each key is \"<type> <name>\" — type first, single space, trailing dot on <name>'\n\n  dns-response-policy:\n    resource: DNS Response Policy\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy.default[0]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>'\n    asset_type: dns.googleapis.com/ResponsePolicy\n    verified: false\n\n  dns-response-policy-rule:\n    resource: DNS Response Policy rule\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>'\n    asset_type: dns.googleapis.com/ResponsePolicyRule\n    verified: false\n\n  # --- KMS ----------------------------------------------------------\n\n  kms-keyring:\n    resource: KMS KeyRing\n    module: modules/kms\n    address: 'module.<instance>.google_kms_key_ring.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>'\n    asset_type: cloudkms.googleapis.com/KeyRing\n    verified: r13\n\n  kms-crypto-key:\n    resource: KMS CryptoKey\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>'\n    asset_type: cloudkms.googleapis.com/CryptoKey\n    verified: r13\n\n  kms-crypto-key-iam:\n    resource: KMS CryptoKey IAM\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>'\n    asset_type: null\n    verified: r13\n\n  # --- Certificate Authority Service --------------------------------\n\n  cas-ca-pool:\n    resource: CAS CA Pool\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name>'\n    asset_type: privateca.googleapis.com/CaPool\n    verified: r14\n\n  cas-ca-pool-iam:\n    resource: CAS CA Pool IAM\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role>'\n    asset_type: null\n    verified: false\n\n  cas-ca-pool-iam-conditional:\n    resource: CAS CA Pool IAM (cond.)\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>'\n    asset_type: null\n    verified: false\n\n  cas-certificate-authority:\n    resource: Certificate Authority\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>'\n    asset_type: privateca.googleapis.com/CertificateAuthority\n    verified: false\n\n  # --- VPC Service Controls -----------------------------------------\n\n  vpcsc-access-policy:\n    resource: VPC-SC Access Policy\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_policy.default[0]'\n    import_id: '<policy_id>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessPolicy\n    verified: r15\n    note: 'import ID is the bare numeric policy id, with no accessPolicies/ prefix'\n\n  vpcsc-access-level:\n    resource: VPC-SC Access Level\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/accessLevels/<level_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessLevel\n    verified: r15\n\n  vpcsc-service-perimeter:\n    resource: VPC-SC Service Perimeter\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/ServicePerimeter\n    verified: r15\n\n  # --- Network Connectivity Center ----------------------------------\n\n  ncc-hub:\n    resource: NCC Hub\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_hub.hub[0]'\n    import_id: 'projects/<project_id>/locations/global/hubs/<hub_name>'\n    asset_type: networkconnectivity.googleapis.com/Hub\n    verified: false\n\n  ncc-spoke-ra:\n    resource: NCC spoke (router appliance)\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_spoke.spoke_ra'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: false\n    disambiguation: 'linked_router_appliance_instances spokes only'\n\n  ncc-spoke-raw:\n    resource: NCC spoke (raw — capability gap)\n    module: raw\n    address: 'google_network_connectivity_spoke.<instance>'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: r12\n    disambiguation: 'linked-VPC and linked-VPN spokes, which no Fabric module carries'\n","isInternal":false,"tokens":7875,"sizeBytes":31904},{"name":"cai-blind-spots.md","path":"skills/fabric-importer/references/cai-blind-spots.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/cai-blind-spots.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# CAI blind spots — where the denominator is incomplete\n\nThe completeness gate is only as strong as Cloud Asset Inventory's\ncoverage. An asset type CAI does not model is invisible to BOTH gates\nsimultaneously (not enumerated → not required by coverage; not emitted →\nnot diffed by plan). Treat this list as living documentation: verify and\nextend it per engagement.\n\nThere are three distinct blind spots, and they need different remedies:\n\n1. **CAI does not model the type at all.** The classic case, and what\n   most of this document is about. Remedy: enumerate it by other means\n   and merge it into the same denominator.\n2. **CAI models the type under a different NAME on the surface you\n   queried.** Rarer, much quieter, and not a blind spot in CAI at all —\n   a blind spot in the question. See\n   [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies).\n3. **CAI models the type but omits a FIELD a consumer depends on.**\n   Quietest of all: the sweep succeeds and the payload simply lacks the\n   attribute. See [Field-level blind spots](#field-level-blind-spots).\n\n## The rule\n\n**CAI is the DEFAULT source of the denominator, never the boundary of\nit.** Anything CAI does not support is retrieved by other means and\nmerged into the same denominator, so the completeness gate still has to\naccount for it. \"CAI cannot see it\" is not a reason for an asset to be\nabsent; it is a reason to enumerate it differently.\n\nThe ladder, in order of preference:\n\n1. **CAI** (`gcloud asset list`, `asset search-all-resources`) — default,\n   because one sweep covers a whole hierarchy and returns ancestry.\n2. **`gcloud <service> list|describe`** — the preferred alternative.\n   Read-only, already installed, already authenticated as the run's\n   read-only identity, and machine-readable with `--format=json`.\n   Declare it in the manifest as a native enumerator (below) so the\n   entries land in `inventory.json` like any others.\n3. **The service REST API** (`curl` with `gcloud auth print-access-token`)\n   — only where gcloud has no surface at all. Enumerate out of band,\n   record the exact call in the run report, and treat any assets found\n   as in scope for mapping.\n4. **A signed waiver plus a run-report entry** — the last resort, and a\n   deliberate, attributed decision. \"Not enumerable\" is a fact to\n   publish, never a silence.\n\n`inventory.py` walks steps 1→2 by itself. It ships a table of built-in\nenumerators (`NATIVE_ENUMERATORS`) for types known to be absent from the\nCAI catalogue: declaring such a type in the manifest is enough, and the\ntool announces that it took the gcloud route. A declared type that CAI\nrejects and no enumerator covers fails the run with the remedy, instead\nof a generic enumeration failure. Steps 3 and 4 it cannot enforce — that\nis the operator's honesty, and the run report is where it is spent.\n\n## Surface-dependent type taxonomies\n\nCAI does not have one asset-type taxonomy. It has two, and for a handful\nof Compute families they disagree:\n\n- the **list / export / query / monitor** surface (`gcloud asset list`,\n  this tool's primary sweep) splits a family by scope into **separate\n  asset types**;\n- the **search / analysis** surface (`gcloud asset\n  search-all-resources`) folds those same resources into a **single\n  unified type**.\n\nGoogle documents this per type on the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types),\nbut only in the per-type prose, which is easy to read past:\n\n> `compute.googleapis.com/Address` — Returns global and regional\n> addresses in the search and analysis APIs, and only regional addresses\n> in the list, export, query, and monitor APIs.\n>\n> `compute.googleapis.com/GlobalAddress` — Not available in the analysis\n> and search APIs. Use `compute.googleapis.com/Address` instead in the\n> search and analysis APIs.\n\nThe documented pairs:\n\n| Declared (search taxonomy) | List-surface sibling swept alongside it |\n|---|---|\n| `compute.googleapis.com/Address` | `compute.googleapis.com/GlobalAddress` |\n| `compute.googleapis.com/BackendService` | `compute.googleapis.com/RegionBackendService` |\n| `compute.googleapis.com/Disk` | `compute.googleapis.com/RegionDisk` |\n| `compute.googleapis.com/ForwardingRule` | `compute.googleapis.com/GlobalForwardingRule` |\n\n### Reproducing it, if you ever need to\n\nThe silent-gap condition needs a family whose declared type yields\n**non-zero** while still being incomplete: at least one REGIONAL address\nand at least one GLOBAL address, in the same in-scope project. With only\nglobal addresses present the declared type yields zero, the pre-existing\nzero-yield warning fires, and the tool is loud — a real bug, but not\nTHIS bug. (With several scopes, remember the aggregate warning can stay\nquiet while one scope's own declaration yielded nothing — the per-scope\n`_meta.scopes[].zero_yield_types` record is the loud one there.) A test estate built only from a freshly created global address\ntherefore validates that siblings are swept, and validates nothing about\nthe silence.\n\n### Why this one is worse than an unsupported type\n\nAn unsupported type fails loudly. This one fails **silently, past every\nguard the tool has** — which is why it needed a mechanism rather than a\nsharper error message. Live-run finding: a global Private Service\nConnect address never entered the denominator, because\n\n- the declared type IS supported by `asset list`, so the\n  unsupported-type fallback to `search-all-resources` never fired;\n- the sweep SUCCEEDED, so nothing landed in `SWEEP_FAILURES`;\n- it returned 33 regional addresses, so the zero-yield warning stayed\n  quiet;\n- the asset was never collected, so `apply_level_filter`'s `unknown`\n  safety net never saw it either.\n\nThe operator had already hand-written the correct `import {}` block, so\nthe only signal that fired was `coverage.py` reporting it as an **orphan\nimport block** — and an orphan was read as a coverage-map problem and\nwaived, rather than as evidence that the denominator was short.\n\n**An orphan import block for a resource you know is live is evidence the\ndenominator is short.** Investigate the enumeration before waiving it;\na waiver signed over a short denominator makes the gap permanent and\nattributed to a human.\n\n### What `inventory.py` does\n\n`CAI_SPLIT_TYPES` is a frozen table of the pairs above. Declaring the\nunified type sweeps its list-surface siblings too, and the siblings'\nentries are **retyped back to the declared type**, so one manifest line\nmeans \"all addresses\" — which is what an operator reading the\nsupported-types page will believe it means. This costs **zero extra API\ncalls**: `asset list` takes a comma-separated `--asset-types`, so the\nsiblings ride along in the existing sweep.\n\nNothing is laundered by the retyping:\n\n- each retyped entry carries `cai_list_type` with the type CAI actually\n  returned it as, and that field travels into `coverage.py`'s worklist;\n- `_meta.split_type_sweeps` records declared type, list-surface type,\n  scope and the RAW swept count — what CAI returned, before the\n  subtree/deleted/level filters;\n- collection prints a NOTICE naming every sibling that contributed, and\n  reconciles swept against in-the-denominator itself (e.g. `3 swept …,\n  2 in the denominator (1 excluded by scope/level/deleted filters)`) —\n  the filters apply to retyped entries like to any other asset, and the\n  subtraction is the tool's to show, not the reader's to derive.\n\nA sibling the manifest declares **in its own right** is not remapped: an\noperator who names `compute.googleapis.com/GlobalAddress` explicitly\nwants it accounted as itself, and remapping would make their\nper-declared-type yield read zero.\n\n### Checking the table against live CAI\n\nThe table is a frozen snapshot of a document Google changes. A new split\n— or a renamed pairing — would put the tool straight back into the\nfailure mode the table closes, so there is a probe:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra `search-all-resources` call per scope, restricted to declared\nsplit types. It compares against the **raw** sweep output, before the\nmanifest's subtree filters, because the question under test is taxonomy\nand not scope. Anything the search surface returns that the list sweep\ndid not is **fatal** (exit 3) with the offending resource names: the\ntable is stale and the denominator is incomplete.\n\nThe probe is opt-in, for two reasons: it costs a call per scope, and it\nimports the search index's propagation lag as a possible false positive.\nRun it at least once per engagement, and quote the result in the run\nreport. `_meta.split_parity` is **empty** when the probe did not run; a\nprobe that ran and found nothing is a RECORD whose `only_in_search` is\nempty. Read the record, not the key — \"not checked\" and \"checked, clean\"\nare different claims and must not be reported as the same one.\n\nIf the probe fires, identify the list-surface type of the missing assets\n(`gcloud asset list` with no `--asset-types`, then match on name) and\nreport it: `CAI_SPLIT_TYPES` needs a new entry, which is a reviewed\nchange to a frozen file, not a local patch.\n\nCAI **retiring** a split is the convergence this table is waiting for.\nA sibling type that `asset list` no longer recognises is therefore\nreported as a possibly-stale table, not as a failed run — a sibling is\ntool-supplied, not operator-declared, so it must never fail someone\nelse's collection.\n\n## Field-level blind spots\n\nA missing field in a CAI payload is asymmetric to an unsupported asset type:\n- An **unsupported type fails loudly**: CAI returns `INVALID_ARGUMENT`, the run\n  stops, and a remedy is displayed.\n- A **missing field fails silently**: `.get()` returns `None`, a filter\n  predicate answers \"no\", and the asset unexpectedly stays in or drops out\n  of the denominator while the run appears healthy.\n\nA filter predicate must never depend on a field whose presence in the live\npayload has not been verified. When a discriminator field is unavailable or\nunverified in CAI, the filter must **fail open** — keep the asset in the\ndenominator for a human to waive, never drop it silently.\n\n## Built-in enumerators\n\n| Asset type | How it is enumerated | Status |\n|---|---|---|\n| `iam.googleapis.com/DenyPolicy` | `gcloud iam policies list --kind=denypolicies --attachment-point=cloudresourcemanager.googleapis.com/<container>` | Command shape verified against gcloud 576; payload shape not yet exercised live |\n\nThe table is short on purpose. To qualify, an enumerator has to be\nhierarchy-container-scoped, read-only, JSON-emitting, and produce a key\nthat is stable and unique. Most non-CAI types fail one of those —\nusually the first — and stay in the blind-spot table below, enumerated\nby hand and named in the run report.\n\nThe table is frozen, like the rest of `inventory.py`: it decides part of\nwhat the denominator contains. Nothing about extending it requires\nediting a frozen file, though — a manifest block covers a type the table\ndoes not, and overrides an entry when an operator knows better. An\nenumerator earned in an engagement should come back as a reviewed change\nto the table, so the next run starts with it.\n\n## Declaring a native enumerator\n\nA manifest type entry may carry an `enumerate:` block. The type is then\nnever sent to CAI; the declared gcloud command runs once per in-scope\ncontainer at the type's `levels`, and its JSON becomes inventory\nentries:\n\n```yaml\n    types:   # a scope's own list — the only position types are declared in\n      # IAM deny policies: managed by modules/organization,\n      # modules/folder and modules/project, and absent from the CAI\n      # catalogue entirely.\n      - type: iam.googleapis.com/DenyPolicy\n        levels: [organization, folder]\n        enumerate:\n          # `gcloud` is implicit and is the only executable a manifest\n          # can name. The tool appends --format=json and one container\n          # argument.\n          command: [iam, policies, list, --kind=denypolicies]\n          # Optional: the shape of that container argument, when the\n          # command does not take --organization/--folder/--project=<id>.\n          # Fields: {container} (e.g. organizations/1) and\n          # {container_id} (1).\n          container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n          # Key template. Fields: {container}, {container_id} and\n          # {item.<dotted.path>} into each returned JSON object. Where\n          # the type has a CAI name format, mirror it, so entries from\n          # the two sources dedupe instead of double-counting.\n          key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n```\n\nDeclared in one scope's list, the command runs only for that scope's\ncontainers; another scope may declare the same type with a different\n`enumerate:` block. What it may not do is inherit: per-scope lists\nnever inherit anything, so the block is repeated in every scope that\nneeds it.\n\n**The mechanism is per hierarchy container**, because that is the shape\nof `levels` — the type entry's `levels` intersected with its scope's.\nAn empty intersection is refused when the manifest is parsed (a\nper-scope declaration that can never fire is a mistake, not a\nleftover), unless the entry lists `unknown`. A command that enumerates inside another\nresource — `gcloud storage managed-folders list gs://BUCKET` — cannot be\nexpressed as an `enumerate:` block: there is no container flag to fill\nin. Those types are enumerated out of band and named in the run report\n(step 3 of the ladder), or waived deliberately. Do not force them into a\nblock by hard-coding one bucket; a container argument that does not vary\nsweeps one place and leaves the rest silently unenumerated, which is why\nthe tool refuses it.\n\nRun the command by hand once before committing the block: the key\ntemplate has to match the payload the command actually returns, and a\nfirst run is the cheapest place to find out that it does not. A field\nthat is absent is a hard failure, and a yield of zero is reported\nloudly — neither is silent, but neither belongs in a run either.\n\nGuard rails, all fail-closed, all for the same reason — a native\nenumerator is a hand-written piece of the denominator, and the\ndenominator is the one thing in this skill nobody is allowed to shrink\nquietly:\n\n- the command must end in a read-only verb (`list`, `describe`, `get`,\n  `search`); anything else is refused before a single call is made;\n- `--format`, `--filter`, `--limit`, `--page-size`, `--flatten`,\n  `--sort-by` and `--uri` are refused. Narrowing belongs in the\n  manifest's `scope` block, where it is reviewed; a `--filter` inside an\n  enumerator shrinks the denominator with no trace;\n- the command may not select an identity — impersonation is set once for\n  the whole run via `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`;\n- a key template that renders the same key for two items, or that\n  references a field the payload does not have, is a hard failure. Two\n  assets collapsing onto one key is a shrunken denominator with a green\n  gate;\n- every native sweep is stamped into `inventory.json`'s `_meta`\n  (`native_sweeps`: type, verbatim command, container, yield) and\n  announced on stderr. A reviewer can re-run each command by hand.\n\nThe manifest is human-owned and committed at the scope-approval gate,\nwhich is what makes an operator-supplied command acceptable at all. An\nagent may draft one; a human signs it, exactly as with waivers.\n\n## Known or suspected gaps (verify per engagement)\n\n| Area | Concern | Mitigation |\n|---|---|---|\n| Service coverage | CAI supports several hundred asset types but not every GCP service/resource; niche or very new resources may be absent | Check the [CAI supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types) for every service the user cares about; for uncovered types, declare a native enumerator (above) so they still enter the denominator |\n| Wrong type string | A type string that does not exist in the catalogue is not a blind spot but a typo, and it presents as one. `gcloud asset list` answers `INVALID_ARGUMENT: No supported asset type matches: <type>` | `inventory.py` classifies that error separately from a permission failure and stops with instructions. Live example: `logging.googleapis.com/OrganizationSettings` does not exist — CAI models the Logs Router settings singleton as `logging.googleapis.com/Settings` (supported at `organization` and `folder` levels only; not indexed at `project` level) |\n| Right type string, wrong surface | A type that is correct for `search-all-resources` can cover only PART of its family under `asset list`, which splits some Compute families by scope into separate types. No error, non-zero yield, silent gap. Live example: a global PSC address typed `compute.googleapis.com/GlobalAddress` by the list surface, absent from a `compute.googleapis.com/Address` sweep | **Handled automatically** for the documented pairs — `CAI_SPLIT_TYPES` sweeps the siblings and accounts them under the declared type. Run `--verify-search-parity` once per engagement to check the frozen table against live CAI; see [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies) |\n| IAM deny policies | `iam.googleapis.com/DenyPolicy` is not a CAI asset type. Fabric manages deny policies at all three container levels (`iam_deny_policies`) | **Handled automatically** — built-in enumerator; just declare the type |\n| Log exclusions | `logging.googleapis.com/LogExclusion` is not a CAI asset type, and current `gcloud` has no `logging exclusions` group either — the ladder falls through to the REST API (`v2/{parent}/exclusions`). Fabric manages exclusions in `modules/organization`, `modules/folder` and `modules/project` | Enumerate out of band, map them, and record the enumeration method in the run report; do not let their absence from CAI read as absence from the estate |\n| Org-policy content-type lag | `--content-type=org-policy` can lag behind newly introduced v2 constraints; the `orgpolicy.googleapis.com/Policy` resource asset stream is more complete | `inventory.py` merges both CAI streams; keep cross-checking counts with `gcloud org-policies list` |\n| Org-policy dry-run specs | CAI `orgpolicy.googleapis.com/Policy` resource stream returns policies where `spec` is present (with or without `dryRunSpec`), but completely **omits dry-run-only policies** where `spec` is unset | `inventory.py collect` sweeps `gcloud org-policies list` per in-scope container and merges by key, so dry-run-only policies enter the denominator automatically |\n| IAM conditions | The `iam-policy` content type returns version-3 policies with conditional bindings intact | Verified in live testing |\n| IAM on leaf assets | `inventory.py` restricts the `iam` pseudo-type to container assets (`Organization`, `Folder`, `Project`); leaf IAM needs explicit per-type manifest entries (`iam: true`) | Documented in the manifest reference |\n| Audit configs | `auditConfigs` block is present and fully preserved in the CAI `iam-policy` payload | Verified in live testing |\n| Deleted / pending-delete resources | CAI reflects live state; soft-deleted roles or pending-delete projects may or may not appear | Decide policy per type; document in the run report |\n| Propagation lag | CAI can lag live changes by minutes | Re-run inventory immediately before the final gate pass; treat count mismatches with service APIs as failures, not noise |\n| Cloud Storage below the bucket | The Cloud Storage section of the CAI catalogue contains exactly one type, `storage.googleapis.com/Bucket`. Objects (`google_storage_bucket_object`), managed folders (`google_storage_managed_folder`) and managed-folder IAM are not modelled | `gcloud storage ls` / `gcloud storage managed-folders list` take a bucket URL, not a container flag, so they do not fit an `enumerate:` block. Enumerate per bucket out of band, map, and record the method in the run report |\n| Terraform-only resources | Some `google_*` resources correspond to no asset at all. `google_project_service_identity` triggers creation of a Google-managed service agent: the state row is real, the CAI asset is not, and Fabric's `modules/project` handles service agents internally | Map through the owning module and note it in the report; there is nothing to enumerate and nothing to waive |\n| Leaf IAM read as a missing type | `google_storage_bucket_iam_binding`, `google_tags_tag_value_iam_binding` and friends are not asset types. They are the `iam-policy` content type on an asset CAI already models, and enter the denominator via `iam: true` on the PARENT type entry | Never waive these as unsupported. `manifest_from_state.py` maps the common ones to their parent with the flag already set |\n| Data-plane / child resources | Some child resources (e.g. per-bucket notification configs, dataset ACL entries) are attributes of the parent in CAI, not assets | The plan gate covers them once the parent is imported; note them in the report if the user expects per-child coverage |\n| Access Context Manager | CAI `asset list --content-type=resource` rejects ACM types, requiring `asset search-all-resources`; `parentFullResourceName` lacks standard container prefixes | Handled in `inventory.py` via dedicated search and level classification |\n\n## Rules\n\n1. When a scope's `types:` list declares a type, confirm it appears in\n   the CAI supported-types list. If it does not, it gets a native enumerator, an\n   out-of-band enumeration recorded in the report, or a signed waiver —\n   one of the three, always. Never a quiet removal from the manifest.\n2. Any count mismatch between CAI and a service API is a **failure to\n   investigate**, never noise to average over. The same applies between\n   CAI's own two surfaces: `asset list` and `search-all-resources`\n   disagreeing about a type is a taxonomy split to identify, not a\n   discrepancy to pick a winner from.\n3. An **orphan import block** reported by `coverage.py` for a resource\n   you know is live is evidence the denominator is short. Investigate\n   the enumeration first; a waiver over a short denominator makes the\n   gap permanent and signs a human's name to it.\n4. Every entry that did not come from CAI is named in the run report,\n   with the command that produced it. `_meta.native_sweeps` in\n   `inventory.json` carries this for declared enumerators;\n   `_meta.split_type_sweeps` carries entries that came from CAI under a\n   different type than the one declared; out-of-band enumeration is on\n   you to quote. Per-scope yields live in\n   `_meta.scopes[].declared_types` / `zero_yield_types` — the aggregate\n   table can hide a scope whose own declaration yielded nothing.\n5. A scope's `types:` list narrows the denominator, so it belongs to\n   the Scope Approval gate like any other narrowing. A convergence\n   claim is bounded per scope, not per manifest — report it that way.\n6. New blind spots discovered during an engagement get added here (this\n   file is reference documentation, not a frozen tool).\n","isInternal":false,"tokens":5279,"sizeBytes":23184},{"name":"inferring-manifests-from-state.md","path":"skills/fabric-importer/references/inferring-manifests-from-state.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/inferring-manifests-from-state.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Inferring Import Manifests from Terraform State\n\nWhen migrating or refactoring existing Google Cloud infrastructure managed by Terraform into Cloud Foundation Fabric (CFF), inferring the `import-manifest.yaml` directly from existing Terraform state file(s) (`.tfstate`) is the most precise and deterministic way to define the import boundary.\n\n---\n\n## Why State-Driven Manifest Generation?\n\n| Approach | Strength | Trade-off / Note |\n| :--- | :--- | :--- |\n| **State-Driven Inference (`manifest_from_state.py`)** | Exactly matches the existing Terraform management footprint. No risk of accidentally pulling unmanaged live assets into the stage scope. | Requires access to state files (`terraform state pull` or GCS bucket access). |\n| **Live Asset Survey (`inventory.py survey` + `manifest_init.py`)** | Discovers all live cloud assets, including unmanaged or out-of-band resources. | Broader denominator; requires manually waiving pre-existing or external assets. |\n\n---\n\n## Workflow\n\n### 1. Retrieve the Terraform State(s)\n\nPull the state files for the target stages or root modules:\n\n```bash\n# Example for FAST Stage 0\ngsutil cp gs://<iac-core-project>-iac-org-state/default.tfstate stage-0.tfstate\n\n# Or pull via Terraform CLI\nterraform -chdir=path/to/stage-0 state pull > stage-0.tfstate\n```\n\nFor multi-stage environments (e.g., FAST stages 0, 1, 2), you can pull states for all relevant stages:\n```bash\nterraform -chdir=fast/stages/0-org-setup state pull > stage-0.tfstate\nterraform -chdir=fast/stages/1-vpcsc state pull > stage-1.tfstate\nterraform -chdir=fast/stages/2-networking state pull > stage-2-networking.tfstate\nterraform -chdir=fast/stages/2-security state pull > stage-2-security.tfstate\n```\n\n### 2. Generate the Manifest\n\nRun `manifest_from_state.py`:\n\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate \\\n  --out import-manifest.yaml\n```\n\nTo synthesize a manifest across multiple stages (note `--force`: the\nmanifest is human-owned and gate-relevant, so it is never overwritten\nsilently — use `--out -` to review on stdout first):\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate stage-1.tfstate stage-2-networking.tfstate stage-2-security.tfstate \\\n  --out import-manifest.yaml --force\n```\n\nAll state files must belong to the same organization: a state spanning\nseveral is refused rather than silently resolved to one of them.\n\nWhen running on an isolated stage state (e.g. `stage-2-networking.tfstate`) that contains resources across multiple folders without including an organization resource in that state, specify the intended scope root with `--root`:\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-2-networking.tfstate \\\n  --root organizations/123456789012 \\\n  --out import-manifest.yaml\n```\n\n### 3. Review and Collect\n\nReview the generated `import-manifest.yaml`, then collect the live denominator:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\n---\n\n## Type & Level Mapping Reference\n\nThe inference script automatically maps Terraform `google_*` resources to CAI asset types and container levels:\n\n| Terraform Resource Type | Manifest `type` | Container Levels | Special Handling |\n| :--- | :--- | :--- | :--- |\n| `google_organization_iam_*` | `iam` | `[organization]` | Authoritative & additive bindings |\n| `google_folder_iam_*` | `iam` | `[folder]` | Folder IAM |\n| `google_project_iam_*` | `iam` | `[project]` | Project IAM |\n| `google_service_account_iam_*` | `iam.googleapis.com/ServiceAccount` | `[project]` | Emits `iam: true` on SA type |\n| `google_org_policy_policy`, `google_organization_policy` | `org-policy` | `[organization, folder, project]` | Inferred from parent attribute |\n| `google_organization_iam_custom_role` | `iam.googleapis.com/Role` | `[organization]` | Org custom roles |\n| `google_project_iam_custom_role` | `iam.googleapis.com/Role` | `[project]` | Project custom roles |\n| `google_logging_organization_sink` | `logging.googleapis.com/LogSink` | `[organization]` | Org log sinks |\n| `google_logging_folder_sink` | `logging.googleapis.com/LogSink` | `[folder]` | Folder log sinks |\n| `google_logging_project_sink` | `logging.googleapis.com/LogSink` | `[project]` | Project log sinks |\n| `google_logging_project_bucket_config`| `logging.googleapis.com/LogBucket` | `[project]` | Project log buckets |\n| `google_logging_organization_settings` | `logging.googleapis.com/Settings` | `[organization]` | Logs Router settings singleton (CAI has no `OrganizationSettings` type) |\n| `google_logging_folder_settings` | `logging.googleapis.com/Settings` | `[folder]` | Logs Router settings singleton |\n| `google_essential_contacts_contact` | `essentialcontacts.googleapis.com/Contact` | inferred from `parent` | Essential contacts |\n| `google_storage_bucket_iam_*` | `storage.googleapis.com/Bucket` | `[project]` | Emits `iam: true` on the bucket type |\n| `google_tags_tag_value_iam_*` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Emits `iam: true` on the tag value type |\n| `google_folder` | `cloudresourcemanager.googleapis.com/Folder` | `[organization, folder]` | Top-level and nested folders |\n| `google_project` | `cloudresourcemanager.googleapis.com/Project` | `[organization, folder]` | Projects |\n| `google_storage_bucket` | `storage.googleapis.com/Bucket` | `[project]` | GCS Buckets |\n| `google_service_account` | `iam.googleapis.com/ServiceAccount` | `[project]` | Service Accounts |\n| `google_tags_tag_key` | `cloudresourcemanager.googleapis.com/TagKey` | `[organization]` | Resource Manager Tag Keys |\n| `google_tags_tag_value` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Tag Values |\n| `google_tags_tag_binding` | `cloudresourcemanager.googleapis.com/TagBinding` | `[organization, folder, project]` | Tag Bindings |\n| `google_project_service` | `serviceusage.googleapis.com/Service` | `[project]` | Enabled APIs |\n| `google_access_context_manager_*` | `identity.accesscontextmanager.googleapis.com/*` | `[organization]` | Access Policies, Perimeters, Levels |\n| `google_compute_network` | `compute.googleapis.com/Network` | `[project]` | VPCs |\n| `google_compute_subnetwork` | `compute.googleapis.com/Subnetwork` | `[project]` | Subnets |\n| `google_compute_global_address` | `compute.googleapis.com/Address` | `[project]` | Global IP Addresses |\n| `google_compute_router` | `compute.googleapis.com/Router` | `[project]` | Cloud Routers & NAT |\n| `google_compute_firewall` | `compute.googleapis.com/Firewall` | `[project]` | Firewall rules |\n| `google_compute_firewall_policy` | `compute.googleapis.com/FirewallPolicy` | `[organization, folder]` | Hierarchical Firewall Policies |\n| `google_compute_network_firewall_policy` | `compute.googleapis.com/NetworkFirewallPolicy` | `[project]` | Network Firewall Policies |\n| `google_compute_ha_vpn_gateway`, `google_compute_vpn_gateway` | `compute.googleapis.com/VpnGateway` | `[project]` | HA & Classic VPN Gateways |\n| `google_compute_external_vpn_gateway` | `compute.googleapis.com/ExternalVpnGateway` | `[project]` | External VPN Gateways |\n| `google_compute_vpn_tunnel` | `compute.googleapis.com/VpnTunnel` | `[project]` | VPN Tunnels |\n| `google_dns_managed_zone` | `dns.googleapis.com/ManagedZone` | `[project]` | Cloud DNS Zones |\n| `google_dns_response_policy` | `dns.googleapis.com/ResponsePolicy` | `[project]` | Cloud DNS Response Policies |\n| `google_network_connectivity_hub` | `networkconnectivity.googleapis.com/Hub` | `[project]` | NCC Hubs |\n| `google_network_connectivity_spoke` | `networkconnectivity.googleapis.com/Spoke` | `[project]` | NCC Spokes |\n| `google_storage_managed_folder` | `storage.googleapis.com/ManagedFolder` | `[project]` | Storage Managed Folders |\n| `google_storage_managed_folder_iam_*` | `storage.googleapis.com/ManagedFolder` | `[project]` | Emits `iam: true` on ManagedFolder |\n| `google_kms_key_ring` | `cloudkms.googleapis.com/KeyRing` | `[project]` | KMS Key Rings |\n| `google_kms_crypto_key` | `cloudkms.googleapis.com/CryptoKey` | `[project]` | KMS Keys |\n| `google_bigquery_dataset` | `bigquery.googleapis.com/Dataset` | `[project]` | BigQuery Datasets |\n| `google_pubsub_topic` | `pubsub.googleapis.com/Topic` | `[project]` | Pub/Sub Topics |\n| `google_pubsub_subscription` | `pubsub.googleapis.com/Subscription` | `[project]` | Pub/Sub Subscriptions |\n| `google_secret_manager_secret` | `secretmanager.googleapis.com/Secret` | `[project]` | Secret Manager |\n| `google_iam_workload_identity_pool` | `iam.googleapis.com/WorkloadIdentityPool` | `[project]` | WIF Pools |\n| `google_iam_workload_identity_pool_provider` | `iam.googleapis.com/WorkloadIdentityPoolProvider` | `[project]` | WIF Identity Providers |\n\n### Types this table does not list\n\nA `google_*` type absent from the table is reported as unmapped, and the\nwarning is precise about what that means: **this tool has no static\nTerraform-to-CAI row for it**. It is not a statement about Cloud Asset\nInventory. Triage each one — CAI may model it (add the type by hand), it\nmay be leaf IAM on a type CAI models (`iam: true` on the parent), CAI may\ngenuinely not model it (`enumerate:` block or out-of-band enumeration,\nsee [cai-blind-spots.md](./cai-blind-spots.md)), or it may be deliberately\nout of scope (signed waiver). Anything you resolve is worth sending back\nas a `TF_TYPE_MAP` entry.\n\n---\n\n## Multi-Scope Partitioning\n\nWhen resources span both Organization/Folder governance and project-contained resources, `manifest_from_state.py` produces a multi-scope configuration:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: stage-projects\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111   # prj-prod-audit-logs-0\n      - projects/222222222222   # prj-prod-iac-core-0\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n```\n\nThis prevents project-level queries from accidentally scanning every unrelated project in the organization while still managing org-level policies and folders.\n\nEvery scope carries its own `types:` list; the generator filters the inferred types per scope (a type whose `levels` cannot fire at a scope's levels is left out, because `inventory.py` refuses dead per-scope declarations) but keeps each entry's full inferred `levels` — the intersection with the scope's `levels` happens at collect time, so the entry reads the same in every scope that carries it. Narrowing a generated list further — dropping a type or a level from one scope but not another — is a normal human refinement of the draft. Regenerating the manifest discards such refinements, so refine after generation, not before.\n","isInternal":false,"tokens":2718,"sizeBytes":11039},{"name":"manifest-migration.md","path":"skills/fabric-importer/references/manifest-migration.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/manifest-migration.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Manifest migration — from the retired grammar to scopes-only\n\nThe import-manifest grammar changed once, and this file is the record of\nit. If `inventory.py` refused your manifest with *\"manifest declares\nretired top-level key(s)\"*, you are holding a manifest written for the\nold grammar; this page is the complete migration path.\n\n## What changed, and why\n\n**Old grammar (retired).** A manifest declared WHERE to look and WHAT to\ncollect in two separate places: a singular `scope:` (or a `scopes:`\nlist) for the subtrees, and one manifest-global `types:` list shared by\nevery scope. The only per-scope discrimination was the `levels` axis\n(organization / folder / project / unknown): a type declared anywhere\nwas swept in every scope whose levels intersected its own.\n\n**New grammar (current).** `scopes:` is the only form, and **every scope\ncarries its own `types:` list**. There is no top-level `types:` and no\nsingular `scope:`. What a scope collects is exactly what is written on\nit.\n\nThe motivation is per-scope type declarations — different resource\ntypes under different subtrees (org governance at org/folder level, the\nconnectivity stack under the networking folder, project IAM only in\nnamed workload projects), which the global list could not express\nwithout waiving every out-of-domain asset by hand. Making the new form\nthe ONLY form, rather than an option next to the old one, makes three\nwhole classes of silent-denominator failure unrepresentable instead of\nmerely validated: there is no inheritance to half-apply, no global list\nto go stale behind the scopes, and no mixed manifest whose meaning\ndepends on which scope the reviewer happened to read.\n\nThe tool refuses the old grammar rather than silently accepting half of\nit, because a half-read manifest would change what the denominator\nmeans — the exact failure the gates exist to prevent.\n\n## Migration: singular `scope:`\n\nWrap the scope as a one-element `scopes:` list and move the `types:`\nlist inside it (indent by four spaces). Nothing else changes — same\ntypes, same levels, same include/exclude, same collected denominator.\n\n```yaml\n# OLD (refused)                     # NEW\nscope:                              scopes:\n  root: organizations/123            - root: organizations/123\n  include:                             include:\n    - folders/456                        - folders/456\ntypes:                                 types:\n  - type: org-policy                     - type: org-policy\n    levels: [organization]                 levels: [organization]\n  - type: iam                            - type: iam\n    levels: [organization, folder]         levels: [organization, folder]\n```\n\n## Migration: `scopes:` list with a shared top-level `types:`\n\nCopy the top-level list into EVERY scope, then (optionally, later)\nnarrow each copy. The unnarrowed copy is semantically identical to the\nold manifest: the collect-time intersection of a type's `levels` with\neach scope's `levels` is unchanged, so the same assets enter the\ndenominator.\n\n```yaml\n# OLD (refused)\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\ntypes:\n  - type: iam\n    levels: [organization, folder, project]\n  - type: cloudresourcemanager.googleapis.com/Folder\n    levels: [organization, folder]\n\n# NEW — verbatim copy first, narrow later\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n```\n\nNote the second scope: `cloudresourcemanager.googleapis.com/Folder`\nwas NOT copied into it. A per-scope entry whose `levels` cannot\nintersect its scope's `levels` is refused as a dead declaration\n(`[organization, folder] ∩ [project] = ∅`), so a verbatim copy is only\nvalid where the type could actually fire. Under the old grammar that\nentry was silently inactive for the scope; now it is an error — drop\nit from the scopes it cannot fire in. Entries whose `levels` include\n`unknown` are exempt and may be copied everywhere.\n\nIf YAML repetition bothers you, anchors keep shared lists in one\nplace:\n\n```yaml\nscopes:\n  - name: teams\n    root: organizations/123\n    include: [folders/111]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: rest\n    root: organizations/123\n    exclude: [folders/111]\n    types: *folder-types\n```\n\n## Key mappings, old → new\n\n| Old | New |\n|---|---|\n| `scope:` (singular) | `scopes:` with one entry |\n| top-level `types:` | each scope's own `types:` (copy, then narrow) |\n| top-level `emission:` (manifest-wide default) | `scopes[].emission` on each scope that wants a non-default style; an omitted family falls back to the built-in default (`per-instance`; `additive` for `iam`), not to another scope |\n| `enumerate:` block, declared once | declared in the `types:` list of every scope that needs the sweep — per-scope lists never inherit, so the block is repeated per scope (a different scope may even carry a different block for the same type) |\n| `iam: true` leaf opt-in, declared once | per scope, like everything else on a type entry: a scope that omits it collects the resource without its leaf IAM |\n| type silently inactive in a scope (empty levels intersection) | refused as a dead declaration (drop the entry from that scope; `unknown` entries exempt) |\n\n## What did NOT change\n\n- The type entry schema: `type`, `levels`, `iam`, `enumerate` are\n  unchanged, including all `enumerate:` guard rails.\n- Scope fields: `name`, `root`, `include`, `exclude`, `levels` are\n  unchanged, including numeric-id resolution for include/exclude.\n- Collect-time semantics for a faithful copy: same sweeps, same\n  intersection rule, same deduplicated denominator. A migrated\n  manifest that copies the old list into every (compatible) scope\n  yields the same `assets` — diffing old and new `inventory.json`\n  asset keys is the cheap proof.\n- Gates and waivers: `coverage.py` and `verify_plan.py` are untouched;\n  existing workspaces, coverage maps and waiver ledgers keep working.\n\n## New output you should start reading\n\n- `_meta.scopes[].declared_types` / `zero_yield_types` — per-scope\n  yield tables. The aggregate `_meta.declared_types` cannot show a type\n  that yields zero in one scope while non-zero in another; the\n  per-scope record (and its stderr warning) can. Quote the per-scope\n  tables in the step-5 report.\n- Every inventory entry carries `scopes: [...]` — the scope(s) that\n  collected it, merged and sorted when scopes overlap.\n\n## Migration checklist\n\n1. Rewrite the manifest as above (or regenerate:\n   `manifest_from_state.py` and `manifest_init.py` emit the new\n   grammar directly).\n2. Run `inventory.py collect` — validation is fail-closed and runs\n   before any API call, so a wrong migration is a refused manifest\n   with the reason, never a shrunken denominator.\n3. Diff the new `inventory.json` asset keys against the last\n   pre-migration one. Identical keys = faithful migration. Missing\n   keys = you narrowed while migrating; deliberate narrowing belongs\n   in its own reviewed manifest change, not folded into the migration.\n4. The manifest is human-owned: the migrated file goes through the\n   same Scope Approval gate as any other manifest edit.\n\nWorked references: `examples/import-manifest.org-foundation.yaml`\n(single scope) and `examples/import-manifest.multi-domain.yaml` (four\nscopes, per-scope types). The fail-closed rules live in SKILL.md\nstep 0 and the cookbook's \"Per-scope type declaration\" subsection.\n","isInternal":false,"tokens":1863,"sizeBytes":7992},{"name":"mapping-cookbook.md","path":"skills/fabric-importer/references/mapping-cookbook.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/mapping-cookbook.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Mapping cookbook — live GCP to Cloud Foundation Fabric\n\nReview-hardened knowledge from the live verification rounds summarised in\n[COVERAGE.md](../COVERAGE.md). Every rule here encodes a bug that was\nactually made and caught; provenance markers like \"(verified r9)\" point\nat the round that proved the rule. Read the section for a resource type\nBEFORE mapping it. When in doubt, read the Fabric module source in this\nrepository — never trust memory for module internals.\n\n**This document is deliberately incomplete, and always will be.** It is\nnot a specification of what the skill supports; it is the precipitate of\na method — the accumulated residue of mapping real resources against a\nlive organization and keeping what survived the gates. A type with no\nsection here is not unsupported. It means nobody has run the method on it\nyet.\n\nSo entries are born the same way every time, and that is the only way\nthey should be born: someone maps a live resource by reading the module\nsource, converges both gates, and writes down what cost them a cycle. See\n\"Step 3b — when the cookbook has never seen this type\" in\n[SKILL.md](../SKILL.md) for the loop. **If you map a type that has no\nsection here, adding one is part of your run report** — that is what\nkeeps this file growing rather than the same traps being paid for on\nevery engagement.\n\nWhat belongs in an entry: the CAI asset-type string as live output\nreturns it, the module addresses and `for_each` key formats, the import\nID format, the ForceNew inputs that must mirror live values, and every\ntrap that cost a cycle. Mark surfaces you did not exercise as unverified\n— an honest gap is worth more than an implied guarantee.\n\nContents: canonical module map · factory emission (opt-in) · universal\nrules · workspace layout · scaffolding scripts · import-ID table ·\nper-type rules (organization, folders, projects, service accounts, log\nsinks, logging buckets, VPC networks, root module) · fallback and\naccelerator.\n\n**Where the fielded data lives.** Address patterns, import IDs, CAI\nasset types and verification rounds are held in\n[`address-map.yaml`](./address-map.yaml) and rendered into the\nimport-ID table below; the prose here owns everything else — the traps,\nthe causal reasoning and the capability gaps. Add a new address to the\nYAML, not to the table, and run\n`uv run scripts/address_map.py` to check both.\n\n## Canonical module map (normative)\n\nFabric modules are the product — this table is the default mapping, not\na suggestion. Raw resources require a documented module capability gap\n(see \"Fallback\" at the end).\n\nEmission has two modes — `per-instance` (the default: one module\ninstance per container, inputs as HCL) and `factory` (data-driven YAML\nconsumed by a Fabric factory carrier). The mode is chosen per resource\nfamily in the manifest's `emission:` block (see \"Factory emission\n(opt-in)\" below); families with `—` in the factory column have exactly\none mode and ignore the knob. Some sub-resources are factory-only by\nFabric convention regardless of the knob: org policies, custom roles\nand custom constraints are carried as `data/` YAML inside their owning\n`organization` / `folder` / `project` instance.\n\n| Scope / resource family | Default (`per-instance`) | `factory` emission — carrier | Carries |\n|---|---|---|---|\n| Organization | `modules/organization` (single instance) | — | org IAM + audit configs, org policies (factory), custom roles (factory), org sinks |\n| Folder | `modules/folder` — one instance per folder | `project-factory` — hierarchy `data/`, one YAML dir per folder, path = key | the folder itself, folder IAM, folder org policies (factory), folder sinks |\n| Project | `modules/project` — one instance per project | `project-factory` — one project YAML per project | the project, services, project IAM, project org policies, metadata |\n| Service accounts | `modules/iam-service-account` | `project-factory` — `service_accounts:` block in the owning project's YAML (only when that project is factory-emitted) | SA + its IAM |\n| Log buckets | `modules/logging-bucket` | — | bucket config (sink destinations) |\n| VPC networks | `modules/net-vpc` | subnets only: `net-vpc` `factories_config.subnets_folder` | network, subnets (incl. proxy-only), routes |\n| VPC firewall rules (legacy) | `modules/net-vpc-firewall` | `net-vpc-firewall` `factories_config.rules_folder` | custom rules + the four `default_rules_config` rules |\n| Cloud NAT & Routers | `modules/net-cloudnat` | — | Cloud NAT + its Cloud Router (NAT-only routers) |\n| HA VPN | `modules/net-vpn-ha` | — | HA VPN gateway, external gateway, tunnels, its own Cloud Router, interfaces, BGP peers |\n| Cloud DNS | `modules/dns` | — (no factory: recordsets are the `recordsets` input, keyed `\"<type> <name>\"`) | managed zones, recordsets |\n| DNS Response Policy | `modules/dns-response-policy` | `dns-response-policy` `factories_config.rules` | response policies & rules |\n| NCC | `modules/ncc-spoke-ra` (hub + router-appliance spokes only) | — | hub; linked-VPC/VPN spokes are a capability gap |\n| Firewall policy rules | `modules/net-firewall-policy` inputs | `net-firewall-policy` `factories_config` rule files | ingress/egress rules |\n| Other | nearest Fabric module for the family; check `modules/` in-repo | see `FACTORIES.md` at the repo root — the authoritative factory catalog (every module factory flows through `factories_config`) | — |\n\nInstance keys: pick short stable slugs at first import (record them in\n`coverage-map.yaml`); keys are immutable afterwards. Because the key —\nnot the display name — defines the address, a console rename plans as\nan in-place `name` update, not destroy/create (verified r6).\n\n## Factory emission (opt-in)\n\n**Status: cookbook-level (C) — rules below are derived from module\nsource in this repository, not yet exercised against live imports.**\nEverything in this section meets that standard and no higher; treat\nunmarked claims as unverified.\n\nThe manifest owns the choice, per resource family. `emission` is a\nmap of family → style, where family names are the canonical-map rows\nand styles are family-specific: `per-instance` (default) or `factory`\nfor module-instance families (those without a factory carrier reject\n`factory`), and `additive` (default) or `authoritative` for the `iam`\npseudo-family (see \"Organization and containers: IAM\"). It lives on\nthe scope entry, like everything else in the manifest: the grammar is\nscopes-only, and there is no top-level position.\n\nResolution, per family:\n\n```text\nscopes[].emission.<family>  →  built-in default (per-instance / additive)\n```\n\nOmission means the built-in default — for `emission` only. **Do not\ngeneralise this to `types:`.** Both keys sit on a scope entry, and\nthere the resemblance stops: `emission` is denominator-neutral (it\ndecides how collected assets are written, and a missing family key\nfalls back safely), while `types:` decides what is collected at all —\nevery scope must declare its full list, and an omission is a refused\nmanifest, not a default. A wrong emission choice still faces both\ngates; a wrong type list shrinks the denominator the gates are\nmeasured against.\n\n```yaml\nscopes:\n  - name: org-foundation\n    levels: [organization, folder]\n    # types: … (each scope carries its own list — elided here)\n    emission:\n      folder: factory        # -> project-factory hierarchy data/\n\n  - name: sandbox\n    levels: [folder, project]\n    include: [folders/333333333333]\n    # types: …\n    # no emission: -> built-in defaults (per-instance; iam additive)\n```\n\n### Subtree-granular emission (scope splitting)\n\nEmission granularity follows scope granularity: to manage *some*\nfolders via factory and others per-instance, split the tree into\nscopes along subtree boundaries and give each its own `emission:` —\nscope `include`/`exclude` already speak subtrees (CAI `ancestors`\nmatching), so no new selector machinery is needed. Remember the\ncoupling: every scope carries its own `types:` list, so a scope added\nfor emission reasons declares its list like any other (usually a copy\nof the list it split from):\n\n```yaml\nscopes:\n  - name: teams               # this subtree is factory-managed\n    root: organizations/000000000000\n    include: [folders/111111111111]\n    levels: [folder]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n    emission:\n      folder: factory\n\n  - name: rest                # everything else stays per-instance\n    root: organizations/000000000000\n    exclude: [folders/111111111111]\n    levels: [folder]\n    types: *folder-types      # same list; YAML anchors avoid drift\n```\n\nRules that make the split sound (from `project-factory` source,\n`folders.tf`):\n\n- **Factory subtrees can be rooted mid-tree.** A level-1 factory\n  folder takes an explicit `parent` in its `.config.yaml`\n  (`parent = coalesce(<config parent>, \"$folder_ids:default\")`),\n  resolvable through `context.folder_ids` — so a factory-managed\n  subtree may sit under a per-instance-managed or unmanaged folder.\n- **One factory instance for the whole workspace.** All\n  factory-emitted folders share a single `project-factory` instance\n  and one `data/` hierarchy, regardless of how many scopes opted in:\n  disjoint subtrees become multiple top-level `data/` directories,\n  each with an explicit `parent`. The factory is workspace-shaped,\n  not scope-shaped — scopes decide what is collected, emission how it\n  is written.\n- **Boundary crossings never use literals.** Downward\n  (per-instance child under a factory folder):\n  `parent = module.<pf>.folder_ids[\"<path>\"]`. Upward (factory\n  subtree under a per-instance folder): `parent:` in the subtree\n  root's `.config.yaml`, fed via the factory's `context.folder_ids`\n  input. Every style boundary is a seam: name it in the run report.\n- **The 4-level cap applies per factory subtree**, since each subtree\n  root is level 1 — splitting deep hierarchies along scope boundaries\n  also relieves the nesting-depth tradeoff.\n\nWhen to opt in: the imported workspace is the intended day-2 operating\nmodel and the family's ongoing management is data-shaped — the\ncanonical case is foundational infrastructure, with the folder tree,\nfolder IAM and org policies maintained as hierarchical YAML. When in\ndoubt, stay per-instance; graduating a family to factory emission later\nis a `moved {}` exercise, per instance, at a time of your choosing.\n\n### Tradeoffs (why per-instance stays the default)\n\n- **Key = data path, blast radius = subtree.** In `project-factory` the\n  hierarchy directory path IS the instance key: renaming or moving a\n  directory re-keys every folder and project below it — a\n  destroy/create plan across the subtree. The same immutability rule as\n  instance keys applies (choose the `data/` layout once, record it in\n  `coverage-map.yaml`), but the coupling is structural, not per-key.\n  D-04 recovery applies unchanged if it ever happens.\n- **Deeper, internal addresses.** Import blocks target factory\n  internals — these are not a stable interface across Fabric refs, so\n  \"read the module source in this repository, do not trust memory\" does\n  double duty here. Re-verify address shapes on every Fabric ref bump.\n- **Nesting depth.** `project-factory` supports at most 4 folder\n  levels (hardcoded `module.folder-1` … `module.folder-4`); the\n  per-instance `modules/folder` chain has no limit. A hierarchy deeper\n  than 4 cannot be factory-emitted.\n- **The reference rule changes carrier.** Inside factory YAML,\n  references are context interpolations (`$folder_ids:<path>`,\n  `$iam_principals:…`), not module outputs. The rule itself is\n  unchanged — no literals for managed resources — only its spelling:\n  - **Level-1 folders** (under the organization or a boundary parent):\n    `parent: \"organizations/<org_id>\"` or `parent: \"$folder_ids:default\"`.\n  - **Nested folders (Level 2+)**: `parent: \"$folder_ids:<parent_path>\"`\n    (e.g. `parent: \"$folder_ids:networking\"` in `networking/production/.config.yaml`).\n    While `project-factory` defaults omitted `parent` on child folders to\n    `\"$folder_ids:${parent_key}\"`, emitting it explicitly makes hierarchy\n    dependencies clear and self-documenting in the YAML data.\n\n### project-factory: addresses and layout (from module source)\n\n- Folders: `module.<pf>.module.folder-<level>[\"<path>\"]` where\n  `<level>` is 1-based directory depth and `<path>` the `data/`-relative\n  directory path. Folder IAM lives on a **separate** paired instance,\n  `module.<pf>.module.folder-<level>-iam[\"<path>\"]` — both must appear\n  in `coverage-map.yaml` for a folder with IAM.\n- Projects: `module.<pf>.module.projects[\"<key>\"]`; service accounts:\n  see `projects-service-accounts.tf` for the key format.\n- Cross-emission references: the factory outputs `folder_ids` /\n  `project_ids` / `iam_principals` keyed by hierarchy path, so mixed\n  mode composes without literals — a per-instance project under a\n  factory folder takes `parent = module.<pf>.folder_ids[\"<path>\"]`.\n  The inverse (factory projects under per-instance folders) goes\n  through the factory's `context.folder_ids` input.\n- Escaping: the factory-YAML rows of the escaping table below apply\n  wholesale; check `templatestring` usage per field with the same\n  `grep -rn templatestring modules/project-factory/` discipline.\n\nOther carriers (`net-vpc` subnet factory, `net-firewall-policy` rule\nfiles, …): same method — read the carrier's `factories_config` source,\nderive address + key formats, and add a subsection here as part of the\nrun report, marking what was exercised. `FACTORIES.md` at the repo root\nis the authoritative catalog of factory carriers.\n\n## Universal rules\n\n### Imports and coverage\n\n- Every emitted resource gets a paired `import` block in the owning\n  instance's `*-import.tf`, and an entry in `tf/coverage-map.yaml`.\n- Deterministic keys: map keys become Terraform addresses; derive them\n  from content, never from list position. For additive IAM use\n  `slug(role)_slug(member)`, with\n  `_sha256(role\\0member\\0title\\0expression)[:8]` appended when\n  conditional; for authoritative conditional bindings use\n  `slug(role)_slug(title)_sha256(role\\0title\\0expression)[:8]`.\n- Run `terraform fmt -recursive` as the last emission step;\n  `terraform fmt -check -recursive` must pass before the gates. Fmt-ed\n  output is the canonical form the determinism and checksum assertions\n  compare (verified plan-invariant, r11).\n\n### Escaping\n\nWhat to escape depends on two things: the carrier (HCL input or factory\nYAML) and whether the module passes the field through\n`templatestring()`. Check with\n`grep -rn templatestring modules/<module>/` — do not guess.\n\n| Field | Carrier | Templated? | Escape `${` as |\n|---|---|---|---|\n| IAM condition expressions (org, folder, project modules) | HCL input | yes | `$$${` |\n| Org-policy values / condition fields / parameters (all levels) | factory YAML | yes | `$${` |\n| Members, titles, filters, descriptions (non-templated inputs) | HCL input | no | `$${` |\n\n`%{` follows the same pattern with `%`. Escape the `${` and `%{`\nsequences only — never blanket-replace `$`: in HCL, `$$` not followed\nby `{` is two literal dollars, so broad replacement corrupts values\n(caught in r7 scaffolding review). Literal `${` round-trip is verified\nend to end (r3, seeded condition title).\n\n### The reference rule\n\nIf a referenced resource is managed in the workspace, refer to it\nthrough its module output — never by literal string:\n`project_id = module.project-<key>.project_id`,\n`parent = module.folder-<key>.id`, `module.sa-<key>.iam_email` for\nmembers where the SA is managed. Literals are only for references that\nleave the managed graph (a billing account, an unimported destination),\nand every such literal is an \"unmanaged reference\" line in the run\nreport. When a referenced resource later graduates into management,\nupgrading the literal to a module output is part of that import's\ncommit (verified r10: sink destinations). Two deliberate exceptions\nstay literal: import block `id`s and `coverage-map.yaml` — they are\njoin artifacts, and determinism and auditability win.\n\n### Scope semantics\n\n- References never widen scope. Managing config that references an\n  unmanaged resource is fine — org sinks converge without their\n  destinations, IAM bindings without their SA principals — as long as\n  convergence does not depend on the referenced side and the report\n  names it. The manifest decides scope.\n- Scopes narrow on two axes: subtrees (`include`/`exclude`) and what\n  is collected inside them (`levels`, and each scope's own `types:`\n  list). Both are Scope-Approval-gate surface: they move the\n  denominator.\n- Manifest `include` / `exclude` match the CAI `ancestors` array,\n  which stores numeric ids. Both spellings work: `projects/123456789`\n  (the project NUMBER) matches directly, and a project ID is resolved\n  to its number by `inventory.py` via `gcloud projects describe` —\n  prefer the number where known, since resolution needs the describe\n  permission. See `examples/import-manifest.org-foundation.yaml` for\n  the include-vs-exclude trap and\n  `examples/import-manifest.multi-domain.yaml` for selecting specific\n  projects per scope.\n\n#### Per-scope type declaration (`scopes[].types`)\n\nEvery scope carries its own `types:` list — there is no other place\nto declare types, so what a scope collects is exactly what is written\non it. The same type may appear in several scopes with different\n`levels`, `iam` or `enumerate`; an `enumerate:` block is swept only\nin the scopes whose list names it.\n\nFail-closed rules (all refuse the manifest before any API call):\n\n- a scope without a `types:` list, or with `types: []`, is refused —\n  an empty list is a scope that collects nothing, and that is the one\n  thing no scope may say quietly;\n- a type whose `levels` cannot intersect its scope's `levels` is\n  refused as a dead declaration (an entry listing `unknown` is\n  exempt): it reads as coverage and produces none;\n- a duplicate `type:` inside one scope's list is refused.\n\nProvenance in `inventory.json`: each `_meta.scopes[]` entry records\nits own `declared_types` yield counts and `zero_yield_types`;\n`_meta.declared_types` is the aggregate; every inventory entry names\nthe scope(s) that collected it (`scopes: [...]`, merged and sorted\nwhen scopes overlap). **Read the per-scope counts, not only the\naggregate** — a type that yields zero in one scope and non-zero in\nanother never appears in the aggregate zero-yield warning. Same\ndiscipline as `_meta.split_parity`: read the record, not the key.\n\n### Serialization traps\n\n- YAML emission: quote YAML 1.1 booleans (`yes/no/on/off`), preserve\n  multi-line strings verbatim (block style, no rstrip), never sort\n  ordered lists.\n- The API omits false booleans in JSON output (`includeChildren` absent\n  means `false`, not `true`).\n- Empty string vs null — **check which of the two shapes you are facing\n  before reaching for a benign rule** (r17):\n\n  1. **Module passes the variable through directly**\n     (`description = var.description`, with a non-empty variable\n     `default`). Setting the input explicitly to `null` round-trips a\n     live empty or unset value cleanly, because Terraform treats an\n     explicit null as \"attribute not present\" and plans no diff.\n     Verified on `modules/bigquery-dataset` (r17): a live dataset with\n     `description = \"\"` planned `\"\" -> \"Terraform managed.\"` until\n     `description = null` was set, after which it imported clean. **This\n     is the preferred fix — a config change, not a rule.**\n  2. **Module wraps the field in `coalesce(var.x, default)`.** Terraform's\n     `coalesce` skips null AND empty strings, so the default always\n     wins and no config value can express \"empty\". `null` does nothing\n     here. This is the only shape where a `when_before`-guarded benign\n     rule is warranted — D-03, verified on org log sinks\n     (`modules/organization/logging.tf` uses\n     `coalesce(each.value.description, \"...\")`).\n\n  Tell them apart with\n  `grep -n '<field>' modules/<module>/*.tf`. Reaching for a rule when\n  shape 1 applies means shipping a benign entry that a one-line config\n  change would have removed. Never an unguarded attribute rule in\n  either case.\n- ForceNew inputs: for every module, check which inputs are ForceNew on\n  the underlying resource and mirror live values exactly BEFORE the\n  first plan. A destroy/create pair on an imported resource is always a\n  mapping error. Known cases: network/subnet `description` (r9), log\n  bucket `parent` format (r10).\n- **Silently dropped module inputs.** Terraform's object type conversion\n  DISCARDS attributes a module's variable type does not declare — with\n  no error and no warning. `terraform validate` returns Success. So a\n  field you believe you have set may never reach the resource, and from\n  the plan alone that looks identical to a provider artifact. Verified\n  against `modules/certificate-authority-service` before #4107: passing\n  `publishing_options` produced a clean validate and the destructive\n  removal diff regardless.\n\n  Diagnosis rule: when a diff persists on an attribute you are certain\n  you have set, read the module's variable TYPE before concluding\n  anything (`grep -A20 'variable \"<name>\"' modules/<module>/variables.tf`).\n  A silently-dropped input is the mechanical tell for a module\n  capability gap, and it is the single easiest way to mistake a gap for\n  benign drift.\n\n## Workspace layout (normative)\n\nOne file per container instance (organization, folder, project) and a\ndata tree that mirrors the hierarchy using instance keys as path\nsegments — never display names, so renames touch nothing and\nreparenting is a cosmetic `git mv`:\n\n```\ntf/\n├── versions.tf  organization.tf  organization-import.tf\n├── folder-<key>.tf / project-<key>.tf     # one per CONTAINER, + -import.tf\n├── coverage-map.yaml\n└── data/\n    ├── organization/{org-policies,custom-roles}/*.yaml\n    └── folders/<key-a>/org-policies/…            # top-level folder\n        └── <key-b>/org-policies/…                # child nested under parent\n            └── projects/<proj-key>/org-policies/…\n```\n\nProject-contained resources live in their project's file. Every module\ninstance whose resource lives inside a project — service accounts,\nbuckets, VPCs, datasets — goes in that project's `project-<key>.tf`\n(import blocks in `project-<key>-import.tf`), not in a file of its own.\nA project file is the complete, reviewable inventory of that project.\nOnly containers get their own files (owner decision after r8).\n\nFile placement is address-neutral (verified r11: plan-invariant).\nRe-homing a module block between files changes no address, no state, no\ncoverage-map entry — so re-layouts are always safe and do not violate\nincremental immutability, which governs addresses and instance keys,\nnever file placement. Do re-layouts as dedicated, mapping-free commits.\n\nEach instance's `factories_config` points at exactly its own subtree.\nModule-input config (IAM, sinks) lives in the instance's `.tf` file;\nonly factory-driven YAML lives under `data/`. The same constraint at\ndifferent levels lands on different paths — no collisions.\n\n## Scaffolding scripts (run-local generators)\n\nWriting a helper script to emit the workspace is legitimate — with\ngovernance:\n\n1. Workspace-confined and org-confidential: scripts embed org data;\n   they live in the workspace (or its private repo), never in the\n   skill. Parameterize by construction — org id and container ids come\n   from the manifest, `inventory.json` or argv, never hardcoded.\n2. The cookbook is the spec; the script is an implementation. A\n   divergence is a defect even while gates are green, because gates\n   only verify paths the live org exercises today (r7 review found four\n   such latent divergences).\n3. Scope comes from the frozen denominator: the set of emitted things\n   joins against `inventory.py` keys. Detail fetches (`describe`,\n   `get-iam-policy`) are fine.\n4. Deterministic: sorted iteration, stable keys, double-run\n   byte-identical, `terraform fmt` as the last step.\n5. No silent promotion: durable knowledge becomes cookbook prose;\n   durable mechanism goes through human review into a tested frozen\n   tool. Never \"the script works, keep it\".\n6. Reported: the run report lists scaffolding used and gate verdicts;\n   commit the script as round evidence (sanitized and parameterized).\n7. Strict address matching: When filtering or mutating import blocks\n   across incremental runs, match full resource type strings (e.g.\n   `google_organization_iam_custom_role`) rather than loose substrings\n   (like `\"custom_role\"`), which collide with role IDs such as\n   `roles/latchkey_custom_role_automation`.\n\n## Import-ID quick table\n\n**Generated — do not edit by hand.** The rows below are rendered from\n[`address-map.yaml`](./address-map.yaml), which owns the fielded subset\nof this cookbook: asset type, module, address pattern, import ID and the\nround that verified it. Edit the YAML and re-render:\n\n```bash\nuv run scripts/address_map.py --render-cookbook\n```\n\nTwo invariants are machine-enforced there and cannot be restated here by\naccident: an address pattern is claimed by exactly one entry, and a CAI\nasset type serviced by more than one module must say how to tell the\ncarriers apart. `<placeholder>` segments are chosen per engagement.\nA blank `Verified` cell means the surface was never exercised live —\nan honest gap, not an implied guarantee.\n\n<!-- BEGIN ADDRESS-MAP (generated) -->\n\n| Resource | Module | Address pattern | Import ID | Verified |\n|---|---|---|---|---|\n| BigQuery Dataset | `modules/bigquery-dataset` | `module.<instance>.google_bigquery_dataset.default` | `projects/<project_id>/datasets/<dataset_id>` | r17 |\n| CAS CA Pool | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool.default[0]` | `projects/<project_id>/locations/<location>/caPools/<name>` | r14 |\n| CAS CA Pool IAM | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role>` | — |\n| CAS CA Pool IAM (cond.) | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>` | — |\n| Certificate Authority | `modules/certificate-authority-service` | `module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]` | `projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>` | — |\n| DNS zone | `modules/dns` | `module.<instance>.google_dns_managed_zone.dns_managed_zone[0]` | `projects/<project_id>/managedZones/<zone>` | r12 |\n| DNS recordset | `modules/dns` | `module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]` | `projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>` | — |\n| DNS Response Policy | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy.default[0]` | `projects/<project_id>/responsePolicies/<response_policy_name>` | — |\n| DNS Response Policy rule | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]` | `projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>` | — |\n| Folder | `modules/folder` | `module.folder-<key>.google_folder.folder[0]` | `folders/<id>` | r6 |\n| Folder IAM | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]` | `folders/<id> <role>` | r6 |\n| Folder IAM (cond.) | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]` | `folders/<id> <role> <condition_title>` | r6 |\n| Folder IAM member (additive, default) | `modules/folder` | `module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]` | `folders/<id> <role> <member>` | — |\n| Folder sink | `modules/folder` | `module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]` | `folders/<id>/sinks/<name>` | r6 |\n| Folder org policy | `modules/folder` | `module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `folders/<id>/policies/<constraint>` | r6 |\n| GCS bucket | `modules/gcs` | `module.<instance>.google_storage_bucket.bucket[0]` | `<project_id>/<bucket_name>` | r12 |\n| Service account | `modules/iam-service-account` | `module.sa-<key>.google_service_account.service_account[0]` | `projects/<project_id>/serviceAccounts/<email>` | r8 |\n| SA IAM binding | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/serviceAccounts/<email> <role>` | r8 |\n| SA IAM binding (cond.) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <condition_title>` | r8 |\n| SA IAM member (additive, default) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <member>` | — |\n| KMS CryptoKey | `modules/kms` | `module.<instance>.google_kms_crypto_key.default[\"<key>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>` | r13 |\n| KMS CryptoKey IAM | `modules/kms` | `module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>` | r13 |\n| KMS KeyRing | `modules/kms` | `module.<instance>.google_kms_key_ring.default[0]` | `projects/<project_id>/locations/<location>/keyRings/<name>` | r13 |\n| Log bucket | `modules/logging-bucket` | `module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]` | `projects/<project_id>/locations/<location>/buckets/<name>` | r10 |\n| NCC Hub | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_hub.hub[0]` | `projects/<project_id>/locations/global/hubs/<hub_name>` | — |\n| NCC spoke (router appliance) | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_spoke.spoke_ra` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | — |\n| Compute address (global PSA) | `modules/net-address` | `module.<instance>.google_compute_global_address.psa[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (global PSC) | `modules/net-address` | `module.<instance>.google_compute_global_address.psc[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (regional internal) | `modules/net-address` | `module.<instance>.google_compute_address.internal[\"<key>\"]` | `projects/<project_id>/regions/<region>/addresses/<name>` | r19 |\n| Cloud NAT | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router_nat.nat` | `projects/<project_id>/regions/<region>/routers/<router>/<nat>` | r12 |\n| Cloud Router | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | r12 |\n| Hierarchical Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_firewall_policy.hierarchical[0]` | `locations/global/firewallPolicies/<id>` | r7 |\n| Global Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_network_firewall_policy.net_global[0]` | `projects/<project_id>/global/firewallPolicies/<name>` | r7 |\n| Regional Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]` | `projects/<project_id>/regions/<region>/firewallPolicies/<name>` | r7 |\n| Interconnect Attachment | `modules/net-vlan-attachment` | `module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]` | `projects/<project_id>/regions/<region>/interconnectAttachments/<name>` | — |\n| VPC network | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_network.network[0]` | `projects/<project_id>/global/networks/<name>` | r3 |\n| VPC route (gateway) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ilb) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (instance) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ip) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (vpn tunnel) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC subnet | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r3 |\n| VPC subnet (private NAT) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | — |\n| VPC subnet (proxy-only) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r9 |\n| VPC firewall rule (custom) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]` | `projects/<project_id>/global/firewalls/<rule_name>` | — |\n| VPC firewall rule (default, admins) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_admins[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-admins` | — |\n| VPC firewall rule (default, http) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_http[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http` | — |\n| VPC firewall rule (default, https) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_https[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https` | — |\n| VPC firewall rule (default, ssh) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_ssh[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh` | — |\n| External VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]` | `projects/<project_id>/global/externalVpnGateways/<name>` | — |\n| HA VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]` | `projects/<project_id>/regions/<region>/vpnGateways/<name>` | — |\n| Cloud Router (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | — |\n| Cloud Router interface (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]` | `<project_id>/<region>/<router_name>/<interface_name>` | — |\n| Cloud Router BGP peer (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>` | — |\n| VPN Tunnel | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/vpnTunnels/<name>` | — |\n| Org audit config | `modules/organization` | `module.organization.google_organization_iam_audit_config.default[\"<service>\"]` | `<org> <service>` | r3 |\n| Custom role | `modules/organization` | `module.organization.google_organization_iam_custom_role.roles[\"<id>\"]` | `organizations/<org>/roles/<id>` | r3 |\n| Org IAM binding (authoritative opt-in) | `modules/organization` | `module.organization.google_organization_iam_binding.authoritative[\"<role>\"]` | `<org> <role>` | r3 |\n| Org IAM binding (cond.) | `modules/organization` | `module.organization.google_organization_iam_binding.bindings[\"<key>\"]` | `<org> <role> <condition_title>` | r3 |\n| Org IAM member (additive, default) | `modules/organization` | `module.organization.google_organization_iam_member.bindings[\"<key>\"]` | `<org> <role> <member>` | — |\n| Org log sink | `modules/organization` | `module.organization.google_logging_organization_sink.sink[\"<name>\"]` | `organizations/<org>/sinks/<name>` | r3 |\n| Org policy | `modules/organization` | `module.organization.google_org_policy_policy.default[\"<constraint>\"]` | `organizations/<org>/policies/<constraint>` | r5 |\n| Tag Key | `modules/organization` | `module.organization.google_tags_tag_key.default[\"<short_name>\"]` | `tagKeys/<id>` | r18 |\n| Tag Value | `modules/organization` | `module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]` | `tagValues/<id>` | r18 |\n| Tag Binding | `modules/organization` | `module.<instance>.google_tags_tag_binding.binding[\"<key>\"]` | `tagBindings/<url-encoded-parent-and-value>` | r18 |\n| PAM entitlement | `modules/project` | `module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]` | `<parent>/locations/<location>/entitlements/<entitlement_id>` | — |\n| Project | `modules/project` | `module.project-<key>.google_project.project[0]` | `projects/<project_id>` | r4 |\n| Project IAM | `modules/project` | `module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]` | `<project_id> <role>` | r4 |\n| Project IAM (cond.) | `modules/project` | `module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]` | `<project_id> <role> <condition_title>` | r4 |\n| Project IAM member (additive, default) | `modules/project` | `module.project-<key>.google_project_iam_member.bindings[\"<key>\"]` | `<project_id> <role> <member>` | — |\n| Project org policy | `modules/project` | `module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `projects/<project_id>/policies/<constraint>` | — |\n| Project service | `modules/project` | `module.project-<key>.google_project_service.project_services[\"<api>\"]` | `<project_id>/<api>` | r4 |\n| Project service (orgpolicy) | `modules/project` | `module.project-<key>.google_project_service.org_policy_service[0]` | `<project_id>/orgpolicy.googleapis.com` | r4 |\n| Workload Identity Pool | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>` | r18 |\n| Workload Identity Pool Provider | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>` | r18 |\n| Pub/Sub Topic | `modules/pubsub` | `module.<instance>.google_pubsub_topic.default` | `projects/<project_id>/topics/<topic_name>` | r17 |\n| VPC-SC Access Level | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]` | `accessPolicies/<policy_id>/accessLevels/<level_name>` | r15 |\n| VPC-SC Access Policy | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_policy.default[0]` | `<policy_id>` | r15 |\n| VPC-SC Service Perimeter | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]` | `accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>` | r15 |\n| NCC spoke (raw — capability gap) | raw | `google_network_connectivity_spoke.<instance>` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | r12 |\n\n<!-- END ADDRESS-MAP -->\n\nVerified across r2–r19 (see COVERAGE.md for the per-family evidence).\nFor anything else: check the provider documentation's import section, or\nemit the import block with the CAI asset name minus the\n`//<service>.googleapis.com/` prefix — verified to match the provider\nimport ID byte-for-byte on every family tested (r5 oracle, 100%) — and\nlet plan tell you: it errors loudly and safely on a wrong ID.\n\n## Per-type rules\n\n### Organization: org policies\n\n- Factory files in `data/.../org-policies/` wrap each policy in a map\n  keyed by the constraint name (`<constraint>: {rules: [...]}`) — the\n  module merges raw YAML via `yamldecode()` (verified r5).\n- `dryRunSpec` is real config: emit it under a `dry_run:` block in the\n  same constraint definition. A dry-run-only policy must never become\n  `rules: []` (verified r4; CAI omits dry-run-only policies — the\n  inventory service-API sweep exists for exactly this).\n- `parameters` must be a JSON string (the module feeds it to\n  `templatestring()`), serialized deterministically\n  (`json.dumps(..., sort_keys=True)`) (verified r3).\n- Factory `fileset()` is non-recursive: filenames are\n  `<constraint>.yaml`, never subdirectories.\n- Value lists are ordered attributes: preserve live order (verified r3).\n- **Verbatim condition expressions**: Condition expressions in org policy\n  rules returned by the API can carry a trailing newline (`\\n`). In factory\n  YAML, use block scalar style (`|`) rather than quoted/stripped strings so\n  the trailing newline is preserved and does not plan as an in-place update.\n\n### Organization: custom roles\n\n- Factory configuration:\n  `factories_config = { custom_roles = \"<path>\" }`.\n- Factory YAML schema: `includedPermissions: [...]` (matching the IAM\n  API attribute), `title`, `description`, `stage` (verified r5).\n- Title/description/stage need a Fabric ref containing PR #4102\n  (released in `v58.0.0`); on `v57.0.0` and earlier they plan as\n  in-place updates (D-01).\n\n### Organization and containers: IAM\n\n- **Additive is the default emission for IAM** (manifest\n  `emission.iam`, styles `additive` | `authoritative`, same two\n  positions and resolution as every other emission family). Each live\n  (role, member, condition) tuple maps to one `iam_bindings_additive`\n  entry → one `google_*_iam_member.bindings[\"<key>\"]`. Why default:\n  an apply can only create or destroy the exact tuples emitted — it\n  can never remove members it does not manage — and it is the\n  non-authoritative posture Google's PAM documentation recommends.\n  The honest tradeoff: additive emission cannot converge out-of-band\n  *additions* away — unmanaged members accumulate invisibly to the\n  plan, while coverage still counts the container policy as mapped.\n  Estates that want IAM fully declarative as the day-2 model opt into\n  `emission.iam: authoritative` (`iam` / `iam_bindings` maps — the\n  emission every existing verified round exercised); the choice is\n  the human's, recorded in the manifest.\n- Assert IAM policy `version: 3` when conditions exist — v1/v2\n  responses flatten conditions, which is silent privilege escalation.\n- `deleted:` principals cannot be imported; exclude them from bindings\n  but always surface them in the report (under additive emission the\n  tombstones simply stay unmanaged; under authoritative emission an\n  apply would remove them).\n- **PAM grant bindings are runtime state, never configuration —\n  excluded from the denominator by `inventory.py`, not by you.** On\n  grant activation, Privileged Access Manager injects a temporary\n  time-bound conditional binding into the container's allow policy and\n  revokes it itself when the grant ends; Google's PAM documentation\n  explicitly warns against managing these bindings in Terraform.\n  Whenever IAM is collected, `inventory.py` also sweeps active grants\n  through CAI (`privilegedaccessmanager.googleapis.com/Grant` — CAI\n  models the Grant, not the Entitlement; one extra call per scope,\n  covered by the same `cloudasset.viewer` grant) and strips matching\n  bindings before normalization. Matching is deterministic — (target,\n  role, requester) come from the grant resource itself, never from\n  string-matching the binding's condition, whose format Google does\n  not publish — and deliberately narrow: conditional bindings only, so\n  a permanent binding coinciding with a grant is kept. A container\n  whose policy holds only grant bindings mints no `#iam` entry:\n  structural exemption, not a waiver. Stripped bindings are stamped in\n  `_meta.pam_grant_exclusions`; quote them in the run report's\n  machine-managed exclusions. At emission time, work from the stamped\n  list — if a live policy shows a conditional binding that looks\n  PAM-shaped but is not in the stamp, the inventory is stale:\n  re-collect, do not classify by hand. Exclusion is also\n  collision-safe: under the additive default nothing unmanaged can be\n  removed by construction, and even under authoritative opt-in Fabric\n  `iam` maps produce bindings authoritative for the (role,\n  no-condition) tuple while PAM bindings always carry a condition, so\n  an apply of the emitted config cannot revoke an active grant. Declaring the Grant type in a manifest is refused. Do not\n  confuse grants with PAM *entitlements*, which are ordinary importable\n  configuration mapping to the `pam_entitlements` factory on\n  `organization` / `folder` / `project`.\n- Audit configs map to `logging_data_access` on `modules/organization`\n  (verified r3).\n- Conditional binding import IDs need a non-empty condition title;\n  format is space-separated:\n  `<org_or_folder_or_project_id> <role> <condition_title>`.\n- **Multi-module state consolidation**: When inferring from Terraform\n  state where IAM for the same scope/role is split across multiple module\n  instances (e.g. `module.organization`, `module.organization-iam`, or\n  dynamic factory loops): under authoritative emission, member lists\n  must be union-merged across all matching state bindings before\n  mapping into canonical module `iam` maps; under additive emission,\n  deduplicate (role, member, condition) tuples across instances\n  instead — each tuple is emitted exactly once.\n- **Verbatim condition expressions & ForceNew**: IAM condition expressions\n  from live API policies frequently contain a trailing newline (`\\n`).\n  Because `condition.expression` on `google_*_iam_binding` is `ForceNew`,\n  stripping whitespace or omitting the newline triggers a spurious\n  destroy/create (replacement) plan. Preserve the exact string from the live\n  API (e.g. via HCL heredoc `<<-EOT` or explicit `\\n`).\n\n### Folders (`modules/folder`, one instance per folder)\n\n- Chain parents by module reference\n  (`parent = module.folder-<parent-key>.id`; top-level folders point at\n  the org). No nesting-depth limit — unlike factory emission, which\n  caps at 4 levels (see \"Factory emission (opt-in)\").\n- Set `deletion_protection = true` (imported provider default; avoids a\n  spurious in-place change — verified r2).\n- Folder-level org policies, sinks and IAM live on the same instance as\n  the folder — never a separate wrapper.\n- Display-name renames are plain `name` updates (verified r6).\n- Re-keying recovery (D-04) — only if instance keys themselves must\n  change, which should be never. If a plan ever shows a destroy/create\n  pair on the same folder id:\n  1. Stop — never let that plan stand, even unapplied.\n  2. Rename the factory path/key to the new slug (content unchanged).\n  3. Add `moved { from = <old address> to = <new address> }` at the\n     workspace root and keep the block permanently.\n  4. Update the `coverage-map.yaml` entry's address in the same commit\n     (the one sanctioned exception to address immutability — the key\n     stays, the address moves with the `moved` block as evidence).\n  5. Re-plan: the pair must collapse to no-op or update before anything\n     else proceeds. Automatic `moved {}` generation is deliberately\n     deferred until a real engagement needs it at scale.\n\n### Projects (`modules/project`, one instance per project)\n\n- Chain parent by module reference or the org id for top-level\n  projects.\n- Set `deletion_policy = \"PREVENT\"`. `auto_create_network = true`\n  matched live state with zero diff on the pilot projects — confirm per\n  project via plan rather than assuming (r7).\n- `services`: the enabled-service list from the live ServiceUsage API.\n  `orgpolicy.googleapis.com` maps internally to\n  `google_project_service.org_policy_service[0]`; every other service\n  maps to `google_project_service.project_services[\"<api>\"]` (r7).\n- CAI can report ghost services (disabled in ServiceUsage but present\n  in CAI): cross-check `gcloud services list` and waive with evidence\n  (r7).\n- `service_agents_config`: set `{ create_primary_agents = false,\n  grant_default_roles = false, grant_service_agent_editor = false }` on\n  imported projects to avoid unwanted resource creation or default role\n  injection (r7).\n- `labels`: pass `{}` or live labels. Provider-computed\n  `terraform_labels` diffs are covered by a `when_before`-guarded\n  benign rule (r7).\n\n### Service accounts (`modules/iam-service-account`) — verified r8\n\n- Manifest: `iam.googleapis.com/ServiceAccount` with `iam: true` —\n  without the opt-in, impersonation grants (IAM on the SA) are\n  invisible to the denominator.\n- Google-managed and service-agent SAs (`*-compute@developer.`,\n  `*@cloudservices.`, `service-*@gcp-sa-*`) must be waived, never\n  imported — they are lifecycle-bound to services, not user config.\n- SA keys: do not import or enumerate user-managed keys (secrets);\n  waive and note in the report.\n- Reference rule: `project_id = module.project-<key>.project_id`;\n  impersonation members that are themselves managed SAs use\n  `module.sa-<key>.iam_email`; unmanaged principals stay literal and\n  are listed in the report.\n\n### Log sinks (org/folder level)\n\n- Never import `_Default` / `_Required` (Google-managed; automatically excluded from the denominator by `inventory.py`).\n- The module `type` enum is `gcs` (not `storage`), `bigquery`,\n  `logging`, `pubsub`, `project` — map the destination API prefix\n  accordingly; fail on an unknown prefix, never rewrite (r7).\n- `destin\n\n<!-- [TRUNCATED AT 50KB] -->","isInternal":false,"tokens":12502,"sizeBytes":50347},{"name":"operating-contract.md","path":"skills/fabric-importer/references/operating-contract.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/references/operating-contract.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# fabric-importer — operating contract\n\nRules for any agent or human operating this skill.\n\n## 1. Trust boundary\n\nThe skill is intentionally lean: the model performs discovery-to-Terraform\nmapping itself, and correctness comes from un-gameable verification, not\nfrom deterministic codegen. That only holds if the boundary is respected:\n\n| Layer | Contents | Who may change it |\n|---|---|---|\n| Frozen scripts | everything in `scripts/`, rulesets included (`benign-drift.yaml`) | Humans, via code review only. The model may run them, never edit them. |\n| Human-owned run files | `import-manifest.yaml`, `waivers.yaml` | Humans. The model drafts/proposes; a human commits. |\n| Model workspace | `tf/`, `coverage-map.yaml`, reports, worklists | The model — with immutable-address discipline (§4). |\n\nA converged verdict is only meaningful if the gates and their rulesets\nwere not touched by the same actor whose output they verify.\n\nBoth gates stamp runtime provenance into their output, covering every\nverification script. To check a recorded verdict, run `uv run\nscripts/integrity.py` from a clean checkout of the same commit and\ncompare; `--verbose` identifies which file differs. This is evidence,\nnot prevention — an actor editing a gate could edit the stamp — but it\nremoves the silent case, and any stored gate output can be checked after\nthe fact.\n\nThe script provenance alone does not prove which *inputs* a gate judged,\nso both gates also stamp every input they read — resolved path and\nSHA256 — on the line after the digest: the plan JSON (or stdin), the\nrules file actually loaded, the inventory, the coverage map, the waiver\nledger, and the set of `*.tf` files scanned. A non-default `--rules`\nfile additionally prints a loud warning and downgrades a converged\nverdict to exit code 3, so a substituted ruleset can never produce a\npassing gate — otherwise\npointing the gate at a permissive file would be a cheaper route to green\nthan editing the frozen file this contract forbids editing. A recorded verdict is therefore attributable to\nexact inputs, not just to a tool build.\n\nThe gates also fail closed on degenerate inputs: a plan without\n`format_version` and `resource_changes` (state JSON, `{}`), a plan with\nzero resource changes without `--allow-empty-plan`, and an empty\ninventory without `--allow-empty-inventory` all exit non-zero. An empty\nverdict is never evidence of convergence.\n\n**Which decisions need a human.** The pattern behind this table is that\nevery human-owned artifact either narrows what the gates check or is\nirreversible. Scope narrows the denominator — on two axes, since each\nscope carries its own `types:` list, and the tool refuses the shapes\nwhere that narrowing would be invisible (a scope without a list, an\nempty list, a declaration whose levels cannot match its scope), because\neach of those would shrink the denominator while still exiting 0. A\nwaiver removes an entry from the denominator; a benign rule removes an\nentry from the residual set; an apply\ncannot be undone. Everything else — module choice, file layout, instance\nkeys, raw-vs-module calls — is falsifiable by the plan and can be\nmodel-owned. Use that test when something new appears: *does this shrink\nwhat is checked, or is it irreversible?* If yes, a human decides.\n\nThe manifest is human-owned for that reason, and it is why a manifest is\nthe only place an operator may declare a native (non-CAI) enumerator —\nin the `types:` list of each scope that needs it, swept only in that\nscope, since per-scope lists never inherit.\nCloud Asset Inventory does not model every GCP resource, and a type it\ncannot see is invisible to both gates at once, so such types are\nenumerated with a read-only `gcloud` command and merged into the same\ndenominator (`references/cai-blind-spots.md`). `inventory.py` ships a\nfrozen table of these for types known to be absent from the catalogue\nand applies them automatically — that is tool-owned, reviewed code, and\na model may no more edit it than any other frozen ruleset. A manifest\nblock covers a type the table does not, or overrides one. That command\nwidens what is checked rather than narrowing it, but it is still a\nhand-written piece of the denominator: `inventory.py` refuses non-read-only verbs, refuses\n`--filter`/`--limit` and friends, refuses a key template that is not\nunique, and stamps every sweep it ran into `inventory.json`'s\n`_meta.native_sweeps`. A model may draft the block; a human commits it.\n\nWaivers therefore carry an optional `signed_by` field. It is reported by\ndefault and enforced with `coverage.py --require-signed-waivers`.\nEnforcement is opt-in on purpose: demanding attribution where no human is\npresent produces invented signatures, and a forged name defeats the audit\nit was meant to support.\n\n## 2. Absolute invariants\n\n1. **NEVER run `terraform apply`** — against any environment, for any\n   reason. The generated authoritative IAM bindings make an apply\n   equivalent to overwriting live org IAM with a stale snapshot.\n2. **Read-only against GCP APIs.** Allowed: `list`, `describe`,\n   `get-iam-policy`, `search`, asset inventory reads, plus\n   `terraform init/validate/plan/show`. Never\n   `set/add/remove/update/create/delete`. Prefer a dedicated read-only\n   identity so the rule is enforced by IAM, not prose.\n3. **Never synthesize Terraform state.** Plan JSON assertions\n   (`verify_plan.py`) are the only accepted convergence evidence.\n4. **Org-confidential output.** Workspace contents and anything derived\n   from a real org (IDs, domains, principals, resource names, counts)\n   never enter a PUBLIC repository, an upstream pull request, or a\n   shareable document unsanitized. Fabric is used fork-and-own, so\n   during the OWN phase these artifacts legitimately live in the\n   operator's own private fork alongside the code — that is the expected\n   working directory, and the boundary to police is publication, not the\n   filesystem. Pulled `.tfstate` and saved `*.tfplan` are the exception\n   in kind, not degree: they contain secret VALUES (Secret Manager\n   payloads, generated keys) and are deleted once the manifest is\n   drafted and the plan verified.\n5. **Never rationalize residual diffs.** A diff believed benign becomes a\n   *proposed* `benign-drift.yaml` entry with evidence, reviewed by a\n   human. Until accepted, the run is red.\n\n## 3. Convergence definition\n\nA run is converged when, for every manifest-declared scope:\n\n1. `coverage.py` exits 0 — every in-scope inventory key is mapped to\n   emitted import blocks or human-waived; and\n2. `verify_plan.py` exits 0 — every planned change is a clean import,\n   no-op, or matches a reviewed benign-drift rule.\n\nBoth are required. The manifest bounds the claim, scope by scope:\nconvergence says nothing about types or levels a scope did not declare,\nand each scope declares its own list — so the run report must state the\nbound per scope, not as a single sentence about \"the manifest\".\n\n## 4. Incremental discipline\n\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable; re-runs extend the workspace for worklist entries only.\n- Genuine re-keying (renames, refactors) requires `moved {}` blocks and a\n  prominent note in the run report.\n- Manifest changes only ever widen or narrow scope; they never license\n  rewriting existing mappings.\n\n## 5. Toolchain\n\nTerraform >= 1.5; google + google-beta providers `>= 7.40, < 8`; Fabric\nmodule refs pinned to released tags (surface unreleased-feature\ntrade-offs to the user instead of silently pinning SHAs). Provider or\nFabric ref bumps invalidate `benign-drift.yaml` verification stamps and\nthe cookbook's address table — re-verify before trusting a verdict.\n","isInternal":false,"tokens":1771,"sizeBytes":7715},{"name":"benign-drift.yaml","path":"skills/fabric-importer/scripts/benign-drift.yaml","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/scripts/benign-drift.yaml","title":"Scripts Skill","category":"anthropic-skill","format":"yaml","content":"# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED benign-drift ruleset for verify_plan.py.\n#\n# Every entry declares a class of plan diffs that is a provider/schema\n# artifact rather than real config-vs-reality drift. Entries require:\n#   - resource:        terraform resource type (\"*\" for any)\n#   - attributes:      top-level attribute paths covered\n#   - reason:          human-reviewed justification\n#   - verified_against: provider (and optionally terraform) version the\n#     justification was checked on. Re-verify on provider major bumps.\n#   - a bound on the DIFF (machine-enforced; an unbounded rule is\n#     ignored with a warning when it would otherwise have applied):\n#       when_after: {attr: value}          exact destination value\n#       when_after_matches: {attr: regex}  destination matches, one line\n#       when_after_computed: [attr, ...]   destination is FULLY unknown at\n#         or true                          plan time (provider-computed)\n#       when_unchanged: [attr, ...]        these attributes must not\n#                                          appear in the diff at all\n#     Every attribute in `attributes` needs a direct destination guard,\n#     OR the rule declares `when_unchanged` naming the live-affecting\n#     siblings that make the diff inert (the `terraform_labels` case:\n#     provider bookkeeping is harmless exactly while `labels` and\n#     `effective_labels` are untouched). `when_unchanged` must be\n#     disjoint from `attributes`.\n#     `when_before` alone is NOT sufficient. A rule that bounds only the\n#     live value accepts an arbitrary destination, so the gate would\n#     report CONVERGED for a plan that really writes to the resource on\n#     apply — the exact silent-pass this ruleset exists to prevent.\n#   Only the diff bound is machine-enforced; `reason` and\n#   `verified_against` are conventions a human reviewer upholds.\n#\n# Keep `attributes` as narrow as possible: changed paths are reported as\n# TOP-LEVEL keys, so listing a block attribute waives every future change\n# anywhere under it.\n#\n# The model operating the skill MUST NOT edit this file. verify_plan.py\n# prints a proposal template for unmatched updates; a human reviews the\n# diff and commits the entry.\n\nrules:\n  - resource: google_folder\n    attributes: [timeouts]\n    when_before:\n      timeouts: {}\n    when_after:\n      timeouts: null\n    reason: >-\n      Google provider v7 emits `timeouts {} -> null` in the import preview\n      for imported folders; the attribute is not a live setting and\n      disappears once state is persisted.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_logging_organization_sink\n    attributes: [description]\n    # Guard: only sinks whose LIVE description is the empty string are\n    # covered; a real description drift on a sink must stay residual.\n    when_before:\n      description: \"\"\n    # Guard: the destination must be the module default, which\n    # modules/organization/logging.tf builds as\n    # `coalesce(each.value.description, \"${each.key} (Terraform-managed).\")`.\n    # Any other new description is real drift and stays residual.\n    when_after_matches:\n      description: '[^\\n]{1,96} \\(Terraform-managed\\)\\.'\n    reason: >-\n      Live GCP log sinks with empty description (\"\") cannot round-trip through\n      Fabric's coalesce(v.description, default) which treats empty string as null\n      and applies the default description.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_compute_subnetwork\n    attributes: [send_secondary_ip_range_if_empty]\n    when_before:\n      send_secondary_ip_range_if_empty: null\n    when_after:\n      send_secondary_ip_range_if_empty: true\n    reason: >-\n      Fabric modules/net-vpc hardcodes send_secondary_ip_range_if_empty = true\n      on google_compute_subnetwork to ensure secondary range removals propagate,\n      which causes an in-place preview diff (null -> true) on imported subnets.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: \"*\"\n    attributes: [terraform_labels]\n    when_before:\n      terraform_labels: {}\n    # Guard: terraform_labels is provider bookkeeping, harmless exactly\n    # while the attributes that really carry labels to the API are\n    # untouched. If `labels` or `effective_labels` move too, this is a\n    # real label write and stays residual.\n    when_unchanged: [labels, effective_labels]\n    reason: >-\n      Google provider v7 computes `terraform_labels: {} -> {...}` during import\n      preview when default/inherited provider labels exist while\n      effective_labels and labels match live state with zero underlying mutation.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n","isInternal":false,"tokens":1208,"sizeBytes":5309},{"name":"SKILL.md","path":"skills/fabric-importer/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fabric-importer/SKILL.md","title":"fabric-importer","category":"anthropic-skill","format":"markdown","content":"---\nname: fabric-importer\ndescription: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n---\n\n# Fabric Importer — agent protocol\n\nYou (the agent) reverse-engineer live GCP resources into Cloud Foundation\nFabric Terraform. There is no per-resource pipeline: **you perform the\nmapping yourself**, guided by the cookbook, and two frozen gates verify\nyour output. Trust comes from the gates, not from you.\n\n## Trust boundary (non-negotiable)\n\n- **Frozen scripts** — everything in `scripts/`, rulesets included\n  (`benign-drift.yaml`). You may RUN them; you must NEVER modify them.\n  The helpers are frozen for the same reason as the gates: they decide\n  the SCOPE the denominator is built from, so editing one shrinks what\n  \"complete\" means. Both gates print runtime provenance and active input hashes;\n  a reviewer compares them against a clean checkout. If a gate is\n  genuinely broken, report it with evidence — do not patch around it.\n- **Human-owned files**: the import manifest and the waiver ledger. You\n  may draft or propose changes; a human reviews and commits them.\n- **Yours**: everything in the workspace (`tf/`, `coverage-map.yaml`,\n  reports). Constraint: once an address exists, it is immutable — extend,\n  never rewrite (see \"Incremental runs\").\n\n## Safety contract\n\n1. **NEVER run `terraform apply`.** The workspace contains authoritative\n   surfaces (org policies, sinks — and authoritative IAM where opted\n   into), so an apply is equivalent to overwriting live config with a\n   snapshot. The additive-IAM default narrows that blast radius; it does\n   not lift this rule. Verification is plan-only via `verify_plan.py`.\n   Never synthesize state files.\n2. **Read-only against GCP**: `list`/`describe`/`get-iam-policy`/asset\n   export only. Prefer a dedicated read-only identity\n   (`roles/cloudasset.viewer` + per-service viewers, via impersonation)\n   so an accidental apply fails with 403.\n3. **All workspace output is org-confidential.** Fabric is used\n   fork-and-own, so during the OWN phase these artifacts legitimately\n   live in the operator's own private fork alongside the code — that is\n   the expected working directory. What must never happen is any of it\n   reaching a PUBLIC repository, an upstream pull request, or a document\n   that leaves the engagement: no org/folder/project ids, principals,\n   resource names or asset counts. Pulled `.tfstate` and saved\n   `*.tfplan` additionally contain secret VALUES (Secret Manager\n   payloads, generated keys); delete them once the manifest is drafted\n   and the plan is verified, and never commit them anywhere.\n4. **Never rationalize a residual diff.** If you believe a plan diff is a\n   provider artifact, propose a `benign-drift.yaml` entry with evidence in\n   your report and stop. A human accepts it or the run stays red.\n\n## Human-in-the-Loop Gates\n\nFour decisions are human-owned and BLOCKING: scope approval (the\nmanifest), waiver signing (`signed_by` in `waivers.yaml`), benign-drift\nacceptance (`scripts/benign-drift.yaml`), and the final apply sign-off.\nEach is called out at its step below; the README's gate table is the\noperator-facing contract. Keep mechanical, reversible steps (asset\ncollection, scaffolding, linting, plan checks) autonomous. If running\nnon-interactively and confirmation cannot be obtained, stop — never\nassume approval.\n\n## Operational Workflow\n\n### Step 0 — establish the manifest (with the user)\n\nThe manifest is the user's contract: which resource types, at which\nlevels, under which subtrees — declared per scope, since `scopes:` is\na list and **every scope carries its own `types:` list**. Never invent\nit silently. If one exists, confirm it; otherwise run one of the\ndrafting workflows:\n\n**Option A — Inferred from existing Terraform state(s)** (preferred when migrating existing TF):\n\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n# add --force to replace an existing manifest, or --out - to review first\n```\n\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate**:\n\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json \\\n  --scope organizations/ORG_ID --out import-manifest.yaml\n```\n\nThen interview the user over the draft (every discovered type is listed\nwith per-level counts, commented out; foundation types are pre-enabled):\n\n- What do you want under Terraform management *now*? (Start narrow —\n  org foundation first, workloads later. Show them\n  `examples/import-manifest.org-foundation.yaml` as the reference.)\n- At which levels? (e.g. org IAM yes, project IAM no.)\n- Same depth everywhere, or different depths per subtree? (Different ⇒\n  several scopes, each with its own `types:` list —\n  `examples/import-manifest.multi-domain.yaml` is the reference.)\n- Any subtrees to exclude (sandboxes, decommissioning trees)?\n\nWhen writing a scope's `include`/`exclude`, note that CAI `ancestors`\nstore NUMERIC project numbers; `inventory.py` automatically resolves\nalphanumeric project IDs to numbers during enumeration, but project numbers\nremain supported and canonical.\n\n**Per-scope `types:` rules (fail-closed, exit 1 before any API call).**\nA scope's list is the only place types are declared, so what a scope\ncollects is exactly what is written on it. The same type may appear in\nseveral scopes with different `levels`, `iam` or `enumerate`. The\nvalidator refuses: a scope without a `types:` list or with `types: []`\n(a scope that collects nothing may not say so quietly); a type whose\n`levels` cannot intersect its scope's `levels` (dead declaration —\nentries listing `unknown` are exempt); a duplicate `type:` within one\nlist; and the retired top-level `scope:`/`types:` grammar, which is\nrejected with migration instructions — a manifest from an earlier\nengagement is migrated per\n[references/manifest-migration.md](./references/manifest-migration.md).\nUnlike `types:`, a scope's\n`emission:` map inherits built-in defaults for omitted families — it\nis denominator-neutral; do not reason from one knob to the other.\n\nThe user commits the manifest. Scope changes later = manifest edit +\nincremental re-run, never a rewrite.\n\n### Step 1 — enumerate (the denominator)\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nBy default, `inventory.py survey` and `inventory.py collect` filter out\nsoft-deleted containers (`DELETE_REQUESTED` / `DELETE_IN_PROGRESS` folders and\nprojects) and their child resources, matching what is active in the GCP Console.\nPass `--include-deleted` if you explicitly need to capture soft-deleted assets in\nthe denominator (e.g. to audit or waive them).\n\nSimilarly, resources GCP creates or manages on the operator's behalf —\nun-creatable auto-generated VPC routes (subnet-local routes, NCC and peering routes),\nGoogle-managed default log sinks and log buckets (`_Default` and `_Required`),\nand ephemeral Privileged Access Manager (PAM) grants (`privilegedaccessmanager.googleapis.com/Grant`) —\nare automatically excluded from the denominator. Pass `--include-auto-generated`\n(or `--include-auto-generated=family,...` e.g. `routes`, `logging-defaults`, `pam-grants`;\n`--include-logging-defaults` and `--include-pam-grants` remain supported as aliases)\nif you explicitly need to capture them in the denominator.\n\n**CAI is the default source of the denominator, never the boundary of\nit.** Cloud Asset Inventory does not model every GCP resource. A type it\ndoes not support must be enumerated by other means and merged into the\nsame denominator — never dropped: an unenumerated asset is invisible to\nBOTH gates at once, which is the exact failure the gates exist to\nprevent. `inventory.py` handles the known cases itself: built-in gcloud\nenumerators for types absent from the CAI catalogue\n(`NATIVE_ENUMERATORS`), automatic sibling sweeps for the Compute\nfamilies whose list surface splits them into separate scoped types\n(stamped in `_meta.split_type_sweeps`, per entry as `cai_list_type`),\nand a hard stop — separated from permission failures — where no\nenumerator exists. When a sweep stops, or a declared type yields less\nthan you expected, read\n[references/cai-blind-spots.md](./references/cai-blind-spots.md) BEFORE\nworking around it: it holds the full remedy ladder (wrong type string →\nsplit CAI taxonomy → native `enumerate:` block, declared in the `types:`\nlist of each scope that needs it, since per-scope lists never inherit →\nREST API), the guard rails for enumerator blocks, and the built-in\ntable. The manifest is human-owned: you draft an enumerator block, a\nhuman commits it; one that worked belongs in your report as a proposed\naddition to the built-in table.\n\nThe split-type table is a frozen snapshot of a doc that changes, so\ncheck it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra call per scope; fatal if the search surface returns an asset\nthe list sweep did not. `_meta.split_parity` EMPTY means the probe did\nnot run; a probe that ran and found nothing is a record whose\n`only_in_search` is empty. Read the record, not the key — and say which\nin the report.\n\nEvery entry that did not come from CAI is stamped into\n`inventory.json`'s `_meta.native_sweeps` with the verbatim command;\nthose entries, and `_meta.split_type_sweeps`, belong in the step-5\nreport. Collection closes with a one-line cost summary (`N gcloud\ncall(s) in Xs: …`) and records every command it ran in\n`_meta.api_calls`; add `--verbose` after the subcommand to watch each\ncall as it happens, and quote the summary line in the report — on a\nlarge estate the per-container org-policy sweep dominates the run, and\nthis is what makes that visible instead of guessed at.\n\n### Step 2 — get your worklist\n\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\nFirst run: everything is missing. Re-runs: only the delta.\n\n### Step 3 — map and emit (your job)\n\n**Fabric modules are the product, not an optimization.** Every resource\nmaps to its canonical Fabric module (the cookbook's \"Canonical module\nmap\" is normative): `modules/organization` for everything org-level,\nand — by default — one `modules/folder` instance per folder and one\n`modules/project` instance per project, each carrying its own IAM, org\npolicies, and sinks. For families where Fabric also offers a factory\ncarrier (e.g. `project-factory` for the folder hierarchy,\n`net-firewall-policy` rule factories), the manifest may opt into\n`factory` emission per resource family via `emission:` — a human call,\nmade when the imported workspace is meant to become the day-2\noperating model (e.g. foundational folders + org policies + IAM as\nYAML data). The default stays per-instance: shallow, chosen addresses\nwith no coupling to factory internals. The cookbook's \"Factory\nemission (opt-in)\" section is normative for how, and for the\ntradeoffs.\n\nFor each worklist entry, emit module config (factory YAML or module\ninputs, per the cookbook) + a paired import block targeting the\nmodule-internal address, maintaining `tf/coverage-map.yaml`\n(inventory key → list of Terraform addresses). Read\n`references/mapping-cookbook.md` FIRST: it encodes the hard-won rules\n(escaping, import-ID formats, coalesce traps, dry-run keys, hashed\ncondition keys).\n\n**IAM is emitted additive by default.** Bindings map to\n`iam_bindings_additive` (one `google_*_iam_member` per role/member/\ncondition tuple): an apply can only create or destroy the exact pairs\nemitted and can never strip members it does not manage — the posture\nGoogle itself recommends for PAM coexistence. Authoritative emission\n(`iam` / `iam_bindings`) is a deliberate opt-in via `emission.iam:\nauthoritative` in the manifest, for estates that want IAM fully\ndeclarative as the day-2 model; the cookbook's container-IAM rules\ncover both and the tradeoff.\n\n**Machine-managed IAM is excluded, never imported — and never part of\nthe denominator.** `inventory.py` strips Privileged Access Manager\ngrant bindings deterministically before the denominator is formed —\nnothing to map, nothing to waive — and stamps them in\n`_meta.pam_grant_exclusions`; report them in step 5, like `deleted:`\nprincipal tombstones (which remain a mapping-time exclusion). PAM\n*entitlements* are ordinary importable configuration; only *grants*\nare excluded. The cookbook's container-IAM rules are normative,\nincluding the strip mechanics.\n\nWhen the cookbook has no section for a type — which is the normal case,\nnot an error — follow the method below. The cookbook is the precipitate\nof that method, not a precondition for it.\n\n**Raw `google_*` resources are the exception, never a shortcut.**\nPermitted only when (a) the canonical module cannot express a required\nattribute of the live resource (a *module capability gap*), or (b) no\nFabric module covers the type at all. Every raw resource MUST appear in\nthe step-5 report's \"module capability gaps\" section with the concrete\nreason. For drafting raw config,\n`terraform plan -generate-config-out=generated.tf` is provider-faithful\nby construction — but it is an inspection technique, not a mapping\ntier.\n\nRead the Fabric module source **in this repository** before targeting\nmodule-internal addresses; do not trust memory. Root-module wiring\n(`versions.tf`, provider config, module blocks) is part of your output.\n\nThree cookbook sections are normative for HOW you emit: **Workspace\nlayout** (one file per CONTAINER; project-contained resources live in\ntheir project's file; hierarchical `data/` keyed by instance keys),\n**The reference rule** (managed resources are referenced via module\noutputs, never literals), and **Scaffolding scripts** (run-local\ngenerators are fine — parameterized, cookbook-conformant, deterministic,\nnever promoted into the skill).\n\n### Step 3b — when the cookbook has never seen this type\n\nThe cookbook will never be comprehensive, and it is not meant to be. It\nis the accumulated residue of running this loop against real resources;\nevery entry in it was produced this way. Meeting an undocumented type is\nroutine — apply the same method, at the same standard of evidence.\n\nWhat makes that safe is division of labour. **The gates make the method\nsafe**: you cannot take a wrong mapping to green, because a bad import\nID errors, a mismatched attribute stays residual, and an unmapped asset\nfails coverage. **The cookbook makes it cheap and correctly diagnosed**:\nit is the difference between converging on the first attempt and the\nthird, and — more importantly — between identifying a diff's cause and\nconfidently misreading it. The gates cannot catch a misdiagnosis, because\nclassifying a diff as benign is a human judgement they delegate. Do not\ntreat an absent cookbook section as licence to reason loosely.\n\nThe loop:\n\n1. **Census the type.** Confirm the CAI asset-type string against live\n   `gcloud asset list` output — type strings from module knowledge have\n   been wrong before, and a non-zero yield is NOT proof the type is\n   complete (split families answer happily with part of the family).\n   Record which enumeration path had to be used. Where the type has any\n   global/regional/zonal distinction, census BOTH surfaces and\n   reconcile the counts before trusting either; where CAI does not\n   model the type at all, enumerate it another way and merge it into\n   the same denominator (step 1, `references/cai-blind-spots.md`) —\n   never narrow the manifest around it.\n2. **Find the module and read its source.** Canonical module map first,\n   then `modules/` in this repository. Read the resource blocks for\n   addresses and `for_each` key formats, and the variables file for the\n   input shape. Never work from memory about module internals.\n3. **Mirror ForceNew inputs to live values BEFORE the first plan.**\n   Check which inputs are ForceNew on the underlying resource and set\n   them to exactly what is live. A destroy/create pair on an imported\n   resource is always a mapping error, never an acceptable outcome.\n4. **Emit, plan, and read the result.** The plan is the oracle. A wrong\n   import ID fails loudly and safely; a wrong attribute shows up as a\n   diff. This step is cheap — use it rather than deliberating.\n5. **Diagnose every diff before classifying it**, in this order:\n   - Read the module's variable TYPE. Terraform silently discards\n     attributes a variable type does not declare — no error, `validate`\n     returns Success — so a field you believe you set may never have\n     reached the resource.\n   - Check whether the field is assigned directly or wrapped in\n     `coalesce()`. Directly assigned fields accept `null` and need no\n     rule; `coalesce` fields cannot express \"empty\" at all.\n   - Check the escaping matrix if the value passes through\n     `templatestring()`.\n   Only after those does the question \"is this benign?\" become\n   answerable.\n6. **Land on one of three outcomes — all legitimate:** clean\n   convergence; a *module capability gap* (the module cannot express an\n   attribute that changes behaviour — emit a raw resource mirroring\n   live values, name the module and the missing attribute in the\n   report's capability-gaps section; a successful outcome and\n   upstream-Fabric-issue material); or *no module covers the type*\n   (same, plus whether a module is worth proposing upstream). A\n   residual you cannot explain is none of these: report it and stop.\n7. **Write the cookbook entry as part of your report.** A type you\n   mapped and verified is knowledge the next engagement should not have\n   to rediscover: the CAI type string, the module addresses and\n   `for_each` key formats, the import ID, the ForceNew inputs, and every\n   trap that cost you a cycle. Mark surfaces you did not exercise as\n   unverified. This step is what makes the tool compound instead of\n   resetting to zero on every engagement — without it, the same traps\n   are paid for repeatedly.\n\n### Step 4 — run the gates\n\n**The two gates measure different things, and neither can catch the\nother's failure.** Gate 1 compares the denominator against the TEXT of\nyour workspace — the `import {}` blocks it parses out of `*.tf`, the\ncoverage map, the waivers. It never runs Terraform and never reads your\ncloud. Gate 2 compares the plan against reality.\n\nSo gate 1 passes happily on code that is completely wrong, and gate 2\npasses happily on a workspace covering three of fifty-nine assets: a\nplan of three clean imports is perfectly converged, because Terraform\nonly knows about what you wrote down. **A converged plan is not evidence\nof completeness**, and a reconciled coverage report is not evidence of\ncorrectness. Never report one as if it covered the other, and never\nskip a gate because the other one is green.\n\n**An orphan import block is evidence about the DENOMINATOR, not just\nabout the coverage map.** When gate 1 reports an `import {}` block\ntargeting a resource that is not in `inventory.json`, and you know that\nresource is live, the first hypothesis is that enumeration missed it —\nnot that the mapping is spurious. This is the only signal that fires for\na silently short denominator, and it has been mistaken for a coverage-map\nproblem and waived away in a live run. Investigate the enumeration path\nfor that type (step 1, and `references/cai-blind-spots.md`) BEFORE\nproposing a waiver: a waiver over a short denominator makes the gap\npermanent and puts a human's name on it.\n\n```bash\nterraform -chdir=tf fmt -recursive   # generated code is ALWAYS fmt-ed\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # never let a stale plan answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` here: it returns **2 whenever the plan is\nnon-empty**, and a converged import plan is always non-empty (every\n`import {}` block is a planned change). Under `set -e`, or to an agent\ntreating non-zero as failure, it aborts the workflow at exactly the step\nthat is meant to validate it. `verify_plan.py` is the verdict, not\nterraform's exit code.\n\nThe `rm -f` matters: `plan` and `show` are separate commands, so if the\nplan step fails (expired credentials, a provider error, a syntax error in\nnewly emitted HCL) `show -json` will happily read the PREVIOUS\niteration's plan file and the gate can print CONVERGED for code that was\nnever planned.\n\n`verify_plan.py` exit codes: `0` converged, `1` malformed input, `2`\nresidual changes, `3` advisory run (a substituted `--rules` file — never\na passing gate).\n\n`--require-signed-waivers` is the default posture: every waiver must\ncarry a `signed_by` recording the human who accepted it. Drop the flag\nonly in a fully unattended run, and say so in the report.\n\nIterate step 3 until **both** gates are green. Convergence =\ncoverage reconciled ∧ every plan change is a clean import, no-op, or\nreviewed-benign.\n\n### Step 5 — report\n\nProduce a run report for the user: what was imported (counts by type,\nwith the Fabric module used for each), an **enumeration sources**\nsection naming every type that did not come from CAI and the exact\ncommand that produced it (`_meta.native_sweeps` covers declared\nenumerators; quote anything you enumerated out of band yourself), a\n**module capability gaps** section — every raw resource emitted, which\nmodule fell short, which attribute, and whether it is\nupstream-Fabric-issue material — plus\nbenign diffs relied on, waivers in effect, proposed waivers/benign rules\nawaiting human review, and anything the manifest excludes that you\nbelieve deserves attention. State whether `--verify-search-parity` was\nrun and what it found. An empty `_meta.split_parity` means the probe did\nnot run; a clean probe is a record whose `only_in_search` is empty.\nReporting an unchecked table as clean is exactly the kind of claim this\nsection exists to prevent. Quote the **per-scope** yield tables\n(`_meta.scopes[].declared_types` / `zero_yield_types`), not only the\naggregate `_meta.declared_types`: a type can be non-zero org-wide while\nthe one scope that declared it yielded nothing, and only the per-scope\nrecord shows that. Convergence claims are bounded per scope — state\neach scope's declared surface, not one sentence about \"the manifest\".\nPlain facts; no \"100%\" claims beyond what the\ngates literally verified.\n\nThe report MUST include the verbatim stdout of both final gate runs —\nincluding the provenance stamp and every `input … sha256:` line.\nThose lines are what lets a reviewer tie the verdict to the exact tool\nbuild and the exact inputs; a report that paraphrases the gates instead\nof quoting them is not evidence.\n\n## Incremental runs\n\n- The workspace persists. `coverage.py`'s worklist is the only to-do\n  list; touch nothing that is already mapped.\n- Commit `.terraform.lock.hcl` in the (user-owned, private) workspace\n  repo: convergence verdicts are only reproducible against the exact\n  provider build that produced them.\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable. If a mapping must genuinely change (e.g. folder renamed),\n  emit `moved {}` blocks and flag it prominently in the report.\n- Manifest NARROWING fails closed: `coverage.py` flags now-out-of-scope\n  mapped keys as `stale?` problems and exits 2. Resolution is a human\n  decision: re-widen the manifest, sign waivers, or deliberately retire\n  the mappings (remove config + import blocks + coverage-map entries in\n  one reviewed commit) — never silently.\n- Manifest widening only ever adds worklist entries; it never invalidates\n  existing mappings.\n\n## Prerequisites\n\n`terraform` >= 1.5, `gcloud`, and [`uv`](https://docs.astral.sh/uv/).\n\nRun every script with `uv run scripts/<name>.py`. Each one declares its\nown dependencies inline (PEP 723), so nothing needs installing or\nactivating and the run cannot pick up a stale system PyYAML. Where `uv`\nis genuinely unavailable, `python3` >= 3.10 with PyYAML installed works\nand the arguments are identical — say which you used in the report.\n\nDiscovery IAM — grant the read-only role set from the README's \"Minimal\nRead-Only IAM Permissions\" table on the scope root. It covers org\nfoundation, folders, projects, networking, and storage; families\nverified later (KMS, CAS, VPC-SC, BigQuery, Pub/Sub, Tags, WIF) may\nneed additional per-service viewer roles — the plan gate names the\nmissing permission when one is needed.\n\n## References\n\n- [README.md](./README.md) — human user guide and architecture overview\n- [COVERAGE.md](./COVERAGE.md) — resource maturity matrix\n- [references/mapping-cookbook.md](./references/mapping-cookbook.md) —\n  Fabric mapping rules, escaping, import-ID table, known traps\n- [references/address-map.yaml](./references/address-map.yaml) — the\n  fielded subset of the cookbook (asset type, module, address, import\n  ID, verification round); validated and rendered by\n  `scripts/address_map.py`, never read by the gates\n- [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md)\n  — inferring import manifests from existing Terraform states\n- [references/manifest-migration.md](./references/manifest-migration.md)\n  — the manifest grammar changed to scopes-only with per-scope\n  `types:` lists; how to migrate a manifest written for the retired\n  grammar, and what stayed identical\n- [references/cai-blind-spots.md](./references/cai-blind-spots.md) —\n  where the CAI denominator is incomplete and what to do about it\n- [references/operating-contract.md](./references/operating-contract.md)\n  — invariants and trust boundaries in full\n","frontmatter":{"name":"fabric-importer","description":"Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform."},"isInternal":false,"tokens":6085,"sizeBytes":26369},{"name":"1-requirements-to-topology.md","path":"skills/fast/networking/references/1-requirements-to-topology.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/networking/references/1-requirements-to-topology.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 1 & 2 Reference: Requirements Discovery & Unified Topology/IPAM Proposal\n\nThis reference governs **Phase 1 (Requirements Discovery)** and **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Rather than asking the user to pick a FAST dataset name upfront—or dumping unrelated questions in static blocks—guide them through a **Conditional Decision DAG** and then present a **single unified Topology + Pre-Filled IPAM Proposal** for confirmation before generating YAML.\n\n---\n\n## 1. Silent Entry Detection (Never Ask a Workflow Meta-Question)\n\nInspect the user's first message and any attached files silently—**never** ask the user a multiple-choice question about how they want to start (interview vs. diagram vs. clone):\n\n- **If the user starts with a general design request (no diagram or detailed spec attached):** Start **Turn 1** of the Conditional Interview DAG (Section 2) immediately. You may include a brief one-line note in prose (*\"If you have an architecture diagram, IP table, or existing dataset you'd like to adapt, feel free to share it at any point\"*), then ask the Turn 1 baseline questions.\n- **If the user attaches or points to an architecture diagram (`.png`, `.jpg`, `.svg`, `.pdf`, `.drawio`) or an IP/subnet table / detailed prompt:**\n  1. Extract all stated facts (Projects, Single VPC vs. Hub & Spokes, regions, subnet CIDRs, interconnect/VPN links, NVAs, NCC hubs, peerings, DNS zones) and infer the required Stage 2 plumbing (e.g., GKE secondary ranges, `/23` `proxy_only: true` subnets for ALBs, `psa_configs` for Cloud SQL/AlloyDB, `$security_profile_groups:` for Cloud NGFW Enterprise).\n  2. Mark **Phase 1** completed and jump straight to **Phase 2 (Unified Topology, IPAM & Plumbing Proposal)** (Section 4), pre-filling sensible defaults for any unspecified CIDRs/ASNs and highlighting only the remaining ambiguities for confirmation.\n- **If the user asks to clone & mutate an existing in-tree dataset:** Jump straight to **Phase 2 (Section 4)** showing the proposed delta and IPAM table against `fast/stages/2-networking/datasets/<name>/`.\n- **If the user only asks to validate an existing dataset on disk:** Run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the results without running the interview.\n\n---\n\n## 2. Phase 1: Conditional Interview DAG (Step-by-Step Discovery)\n\nFollow this conditional flow so you never ask moot questions (such as asking about hubs and spokes when the user selected a Single Shared VPC):\n\n```text\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork                     │\n  │ (Ask together ONLY if not already stated in the user's initial prompt)     │\n  │  1. Stage 0 Integration (`0-org-setup` outputs vs. Standalone `.tfvars`)   │\n  │  2. Concrete Target Region(s) (`1..N` specific regions + shortname keys)   │\n  │  3. VPC Footprint Fork:                                                    │\n  │     • Path A: Single Shared VPC (1 VPC, microsegmented via FW/Tags/NGFW)   │\n  │     • Path B: Multi-VPC Hub & Spokes (Separate VPCs for IAM / quota scale) │\n  └───────────────────┬────────────────────────────────────┬───────────────────┘\n                      │                                    │\n         [Path A: Single Shared VPC]          [Path B: Multi-VPC Hub & Spokes]\n         (SKIP all hub/spoke/NCC/peering       ▼\n          and transit-NVA questions!)     ┌────────────────────────────────────┐\n                      │                   │ Turn 2B (Step 2a): Multi-VPC Scope │\n                      │                   │  1. Which spoke environments?      │\n                      │                   │  2. Inter-VPC reachability &       │\n                      │                   │     transit inspection model?      │\n                      │                   └────────────────┬───────────────────┘\n                      │                                    │\n                      └─────────────────┬──────────────────┘\n                                        ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 2A (if Path A) / Turn 3B (if Path B) (Step 2b):                       │\n  │ Connectivity, Security & Workload Plumbing (tailored to Path A or Path B)  │\n  │  1. Hybrid Connectivity (None / HA VPN / Cloud Interconnect / Both)        │\n  │  2. Internet Egress (Cloud NAT / Air-gapped [or Centralized via NVA])      │\n  │  3. Firewall Posture (Cloud-Native L4 Policies vs. Cloud NGFW Enterprise)  │\n  │  4. Workload Plumbing (None / GKE / L7 ALB `proxy_only` / PSA / PSC / etc) │\n  └────────────────────────────────────┬───────────────────────────────────────┘\n                                       ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Phase 2 (Step 3 — Single Unified Sign-Off Gate):                           │\n  │ Present Topology + Pre-Filled Regional IPAM/ASN/DNS Defaults + Output Path │\n  │ (User confirms defaults or tweaks CIDRs/names -> Proceed to Phase 3 Gen)   │\n  └────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork\nAsk only the following mutually independent questions (skipping any already answered in the user's prompt):\n\n1. **FAST `0-org-setup` Integration vs. Standalone Stage Execution:** Are you running `2-networking` on top of an existing FAST `0-org-setup` deployment, or standalone?\n   - *Why this matters:* `2-networking` can create new projects or reuse existing projects in either mode. Knowing if `0-org-setup` is used is strictly for **tfvars, provider, and context plumbing** (whether `0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` provide `$folder_ids`, `$storage_buckets`, `billing_account`, and `prefix`, or whether those variables must be supplied directly in `2-networking.auto.tfvars`).\n2. **Target Regions (`1..N`) & Location Keys:** Which specific GCP region(s) (`1` to `N`) will host your resources?\n   - *Important:* Present **concrete** single-region and multi-region choices (e.g., `europe-west1 + europe-west8`, `europe-west8 + europe-west12`, `europe-west8 (single region)`, `europe-west1 (single region)`, `us-central1 + us-east4`, or custom free-text) so you never need a second question to ask *which* region they picked.\n   - *Note on `defaults.yaml` location keys:* `primary` and `secondary` in the reference datasets are just arbitrary key names in `context.locations`. A dataset can define `1` to `N` regions using either role names (`primary`, `secondary`) or concise region shortnames (`ew1`, `ew8`, `ew12`, `usc1`). Propose clean shortname keys matching their chosen regions.\n3. **Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`):**\n   - *Framing to include in the question:* In GCP, network microsegmentation and L7 IPS (via Hierarchical/Network Firewall Policies, IAM-governed Secure Tags, and Cloud NGFW Enterprise) work **inside a single Shared VPC**—you do not need multiple VPCs for network security isolation. Multiple VPCs are used to **isolate IAM/administrative blast radii** across host projects (e.g., `dev` vs. `prod`) and to **scale horizontally** across VPC quota domains.\n   - **Option A — Single Shared VPC:** A single Shared VPC (`main-0` / `shared-0`) hosting all environments/subnets, segmented via subnet IAM, Firewall Policies, Secure Tags, and optional Cloud NGFW Enterprise. *(Ideal for PoCs, simple/compact estates, or low-latency enclaves—no hubs, spokes, NCC, or peering required).*\n   - **Option B — Multi-VPC Hub & Spokes:** Separate Shared VPCs per environment (`dev`, `prod`, etc.) interconnected through a central hub (via NCC, 3rd-party NVAs, or Peering).\n\n> **STOP after Turn 1 and wait for the user's answer before asking Turn 2 questions.**\n\n---\n\n### Turn 2 — Path A (`Single Shared VPC` Selected): Single-VPC Plumbing Discovery\nWhen the user selects **Option A (Single Shared VPC)** in Turn 1:\n- **Do NOT ask about spoke environments, spoke-to-spoke routing, NCC hubs, VPC peering, or inter-VPC transit NVAs.**\n- Ask only the Single-VPC questions below:\n  1. **Environment Subnets within the Single VPC:** Do you want a single general-purpose subnet per region, or separate subnets per logical environment (e.g., `dev` and `prod` subnets within the same Shared VPC for subnet-level IAM delegation)?\n  2. **Hybrid Connectivity:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)** terminating in this VPC? *(If Cloud Interconnect is selected, note that Phase 2 will confirm Dedicated vs. Partner mode, peer ASN, and interconnect/VLAN attachment parameters).*\n  3. **Internet Egress:** Should the VPC egress via **Cloud NAT** (`nat_config`), or have **no internet egress** (Air-gapped / Private Google Access only)?\n  4. **Firewall & Inspection Posture:** Are **Cloud-Native L4 Firewalls** (Hierarchical + VPC Firewall Policies) sufficient, or do you also want **Cloud NGFW Enterprise** (L7 IPS / TLS inspection rules via `$security_profile_groups`—or intra-VPC 3rd-party NVA hairpinning via Policy-Based Routing)?\n  5. **Workload & Managed Service Plumbing:** Which platforms will run in this VPC so Stage 2 can pre-provision their subnets/ranges? *(Include an explicit **\"None (standard VM subnets only)\"** option alongside **GKE Clusters** (`pods`/`services` secondary ranges), **Application Load Balancers** (`/23` `proxy_only: true` subnet), **Private Services Access / PSA** (`psa_configs` for Cloud SQL/AlloyDB/Memorystore/NetApp), **Private Service Connect / PSC** (`psc: true` NAT subnets or `psc:` consumer endpoints in `addresses.yaml`), and **Serverless / Vertex AI** (`/24` Direct VPC Egress / `network_attachments`))*.\n\n---\n\n### Turn 2 — Path B (`Multi-VPC Hub & Spokes` Selected): Inter-VPC & Inspection Discovery\nWhen the user selects **Option B (Multi-VPC Hub & Spokes)** in Turn 1, ask:\n\n1. **Spoke Environments:** Which workload environments need dedicated spoke Shared VPCs? *(Default: `dev` and `prod`; common additions: `nonprod`/`uat`, `dmz`)*.\n2. **Inter-VPC Reachability & Transit Inspection Pattern:** How should your spoke VPCs communicate with each other and with shared services/hybrid links?\n   - **Option A — Direct Spoke-to-Spoke over NCC (`hub-and-spokes-ncc` with `MESH`, Recommended Cloud-Native Default):** Full line-rate routing across spokes via NCC `MESH`, with microsegmentation enforced by Firewall Policies, Secure Tags, and/or **Cloud NGFW Enterprise**.\n   - **Option B — Central Hub Group + Isolated Edge Spokes over NCC (`hub-and-spokes-ncc` with `STAR`):** Shared-service/hub VPCs in the `center` group are reachable by all `edge` spokes (`dev`, `prod`), while `edge` spokes are isolated from each other at the routing table layer (strongly favored over traditional peering for scale and transitive hybrid/PSA/PSC routing).\n   - **Option C — Inspected via 3rd-Party Stateful NVA VMs (Palo Alto, Fortinet, Check Point, Linux/FRR):** Spoke-to-spoke and hybrid traffic is hairpinned through multi-NIC NVA VMs.\n     - **Always recommend `hub-and-spokes-nva-ncc` (NCC Router Appliance with dynamic BGP) for multi-regional architectures:** It is the **only** pattern that provides automatic cross-region failover via BGP/MED policies *and* allows managed services (such as PSA-peered Cloud SQL, AlloyDB, or NetApp) to use the best available regional route, since managed services do not support network-tagged static routes.\n     - Use **`hub-and-spokes-nva` (ILB Sandwich with static routes)** only for single-region deployments or specific point-to-point/DMZ `ILB-as-next-hop` legs where BGP is not used.\n   - **Option D — Strictly Isolated Spokes via VPC Peering (`hub-and-spokes-peerings`):** Small static estate (`<= 5` spokes, `$0` hub fee) where `dev` and `prod` peer only with `hub`.\n     - *Advisory warning:* Remind the user that **\"strict spoke isolation\" almost never holds true over time**—scenarios like exchanging anonymized data between `prod` and `dev`, or running shared CI/CD or AI agents that must operate across both environments, will bite them under non-transitive peering.\n\n### Turn 3 — Path B (`Multi-VPC Hub & Spokes`): Hybrid, Egress, Firewall & Workload Plumbing\nOnce the Multi-VPC pattern is selected in Turn 2B, ask the remaining plumbing questions:\n1. **On-Premises / Multicloud Links:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)**?\n   - *(Note: On NCC hybrid spokes, `site_to_site_data_transfer` defaults to `true` in `2-networking`; ask whether external sites need site-to-site transit through GCP (`true`, default) or only communicate with GCP VPCs (`false`, which also avoids errors in regions where S2S is unsupported). If Cloud Interconnect is selected, gather or propose Dedicated vs. Partner mode and `peer_asn`).*\n2. **Internet Egress Model:** Per-VPC **Cloud NAT** (`nat_config`), centralized egress through the NVA/DMZ VPC, or **no internet egress** (Air-gapped / PGA only)?\n3. **Cloud NGFW Enterprise (if Cloud-Native routing was selected in Turn 2B):** Standard L4 Firewall Policies (`goto_next`), or add **Cloud NGFW Enterprise** (`$security_profile_groups` L7 IPS rules)?\n4. **Workload & Managed Service Plumbing:** **None (standard subnets only)**, **GKE** (`pods`/`services` secondaries), **L7 ALB** (`/23` `proxy_only: true`), **PSA** (`psa_configs`), **PSC** (`psc: true` NAT subnets / `psc:` consumer endpoints in `addresses.yaml`), and/or **Serverless / Vertex AI**?\n\n---\n\n## 3. Mapping Requirements to `2-networking` Topologies\n\n```text\n  [Primary VPC Footprint Fork: Single VPC or Multi-VPC Hub & Spokes?]\n   ├── Single Shared VPC (PoCs, simple estates, or single IAM boundary)\n   │    └──> Custom Single-VPC Dataset (1 project + 1 VPC, no NCC/Peering/DNS-peering; derived from peerings dataset)\n   │\n   └── Multi-VPC Hub & Spokes (Separate host-project IAM boundaries or horizontal quota scale)\n        ├── [Do inter-VPC or hybrid flows require 3rd-party multi-NIC NVA VMs as the transit router?]\n        │    ├── Multi-region NVA (Recommended Default) ──> datasets/hub-and-spokes-nva-ncc (BGP Router Appliance)\n        │    ├── Single-region NVA / Static ILB only ─────> datasets/hub-and-spokes-nva (ILB Sandwich)\n        │    └── Hybrid (NCC-RA core + ILB DMZ leg) ──────> datasets/hub-and-spokes-nva-ncc (+ ilb_config on DMZ NIC)\n        │\n        └── [Cloud-native routing, with L4 Firewall Policies and/or Cloud NGFW Enterprise]\n             ├── Recommended Default (Any scale, hybrid/PSA/PSC transitivity) ──> datasets/hub-and-spokes-ncc\n             │    ├── Full spoke-to-spoke routing (microsegmented via FW/NGFW) ─> preset_topology: MESH\n             │    └── Routing-level isolation between edge spokes via center ───> preset_topology: STAR\n             │\n             ├── Small static estate (<= 5 spokes, $0 hub fee) ─────────────────> datasets/hub-and-spokes-peerings\n             │    (Warn user: strict isolation almost always bites later—e.g. exchanging anonymized prod/dev data or running agents across both envs)\n             │\n             └── Legacy / Niche (Not recommended for new designs; NCC wins) ────> datasets/hub-and-spokes-vpns\n```\n\n### Topology Comparison Matrix\n\n| Topology Pattern & In-Tree Baseline | Core Connectivity & VPC Layout | East-West Behavior | Scale & Quota Envelope | Best Fit & Recommendation Posture | Watch Out For... |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| **Single Shared VPC** *(Derived by simplifying `hub-and-spokes-peerings` to 1 project + 1 VPC)* | Single Shared VPC (`vpcs/main/` or `vpcs/shared/`) in 1 host project (`projects/net-core-0.yaml`). Zero `ncc-hubs/`, `nvas/`, or `peering_config`. | **Native intra-VPC routing** at full line rate; segmented via subnet IAM, Firewall Policies, Secure Tags, and/or Cloud NGFW Enterprise. | Up to **300 subnets** and **100–1,000 service projects** in a single quota domain. | **Ideal for PoCs, simple/compact estates, or low-latency enclaves** where a single network admin team manages the host project. | All environments share a single Shared VPC host project IAM boundary and VPC quota domain; intra-VPC 3rd-party appliance hairpinning requires Policy-Based Routing (`policy_based_routes`). |\n| **`hub-and-spokes-ncc`** *(Recommended Multi-VPC Cloud-Native Default)* | Central NCC Hub (`hub.yaml`) connecting `hub-0` (with hybrid VPN/VLAN spokes), `dev-0`, and `prod-0` as VPC spokes. | **Configurable:** Full any-to-any with `preset_topology: MESH`, or isolated edge spokes with `preset_topology: STAR` (`center` vs. `edge` groups). | Scales up to **250 VPC spokes** per hub (`4,000` subnet routes; `1,000` dynamic prefixes/region; `1,000` propagated PSC endpoints). | **Primary recommendation** for multi-VPC cloud-native and Cloud NGFW Enterprise estates; native transitive hybrid routing, PSC propagation (`export_psc: true`), and Producer VPC Spoke support. | `preset_topology` (`MESH` vs. `STAR`) is immutable after hub creation; `site_to_site_data_transfer` on hybrid spokes defaults to `true` (set `false` in regions that do not support S2S). |\n| **`hub-and-spokes-nva-ncc`** *(Recommended Multi-Region NVA Default)* | Dual NCC Hubs (`routing` `MESH` + `transit` `STAR`); multi-region NVAs (`nvas/primary.yaml`, `secondary.yaml`) attached as **NCC Router Appliance** spokes (`ncc_ra_config` + `bgp_config`). | **Inspected:** `dev-0` and `prod-0` attach to `transit` `edge` group; NVAs attach to `transit` `center` and `routing` `default`, inspecting all N-S and E-W flows over dynamic BGP. | Scales to NCC limits (**250 spokes**) with zero static routes and automatic intra-region / cross-region BGP failover. | **Always recommend for multi-region 3rd-party NVA architectures.** Only way to get automatic cross-region failover and let managed services (`PSA`) follow the best available route (since managed services do not support tagged static routes). | Never place workloads in the `transit` VPC (asymmetric return path); every prefix in `advertise` (control plane) must have a matching kernel route in `routes` (data plane). |\n| **`hub-and-spokes-nva`** | Multi-NIC NVA (`nvas/main.yaml`) with `ilb_config` directly attached via 4 NICs to `dmz`, `hub-0`, `dev-0`, and `prod-0` (no `peering_config` between VPCs). | **Inspected:** Static routes in each VPC steer inter-VPC and hybrid traffic to the NVA's per-VPC Internal Passthrough NLB VIP (`$addresses:<vpc>/main`). | Bounded by GCE maximum NICs per VM (`2` to `8` vNICs depending on machine shape, so at most 8 directly attached VPCs) and NVA VM bandwidth. | Single-region 3rd-party NVA deployments with $\\le 8$ VPCs, or specific `ILB-as-next-hop` DMZ/cross-environment legs where dynamic BGP is not used. | Static routes lack regional proximity metrics for multi-region failover; requires symmetric 5-tuple hashing (`PER_CONNECTION`, `session_affinity: NONE`, `NEVER_PERSIST`) and a shared health check. |\n| **`hub-and-spokes-peerings`** *(also symlinked as `classic`)* | `hub-0` peered via `peering_config` with `dev-0` and `prod-0` (`routes_config.export/import: true`). | **Isolated** (VPC Peering is strictly non-transitive). | Small estates (**$\\le 5$ spokes**). Shares peering-group quotas (`subnet_ranges_per_peering_group` = 1,000; `static_routes_per_peering_group` = 300). | Small, cost-sensitive setups (`$0` hub fee). **Caution:** Warn the user that \"strict spoke isolation\" is almost never true long-term—scenarios like exchanging anonymized data between `prod` and `dev` or running agents that work across both environments will bite them under non-transitive peering. | On-premises routes in `hub-0` cannot transit to PSA (`servicenetworking`) peered to `dev-0`/`prod-0`; Cloud DNS forwarding (`35.199.192.0/19`) cannot hop across peering to a VPN in another VPC. |\n| **`hub-and-spokes-vpns`** *(Legacy / Niche)* | `hub-0` connected to `dev-0` and `prod-0` via GCP-to-GCP HA VPN tunnels (`vpcs/<vpc>/vpns/*.yaml`) and Cloud Router BGP. | **Policy-Controlled:** Spokes exchange routes through `hub-0` Cloud Routers, filtered via CEL `route_policies` (`IMPORT`/`EXPORT`). | Up to 5 HA VPN gateways/region/project; bandwidth capped at **1.5–3 Gbps per IPsec tunnel** (`1460` byte MTU). | **Avoid for modern greenfield designs (NCC wins on throughput, cost, and simplicity).** Retain only for legacy brownfield compatibility or hard cross-org IPsec requirements. | IPsec tunnel throughput ceiling and MTU clamping (`1460` / `1406`); VPC firewall rules must allow `icmp` ingress so Path MTU Discovery (`ICMP Type 3 Code 4`) functions. |\n\n---\n\n## 4. Phase 2 (Step 3): Unified Topology, IPAM & Plumbing Proposal Template\n\nDo **not** split Topology Confirmation and IPAM Confirmation into two separate turns. As soon as Phase 1 requirements are gathered (or extracted upfront in Adaptive Mode), present a **single unified proposal** containing both the selected topology and pre-filled default CIDRs/ASNs/DNS domains, and ask the user once to confirm (or specify any custom CIDRs/names/output path) before generating the dataset in Phase 3:\n\n```markdown\n### Proposed FAST `2-networking` Topology & IPAM Blueprint\n\n- **Topology Pattern:** [`Single Shared VPC` | `datasets/<chosen-dataset>`]\n- **Target Output Directory:** `<proposed-or-user-specified-path>` *(e.g. `./fast-config/2-networking/data/`)*\n- **Stage 0 Integration:** [FAST `0-org-setup` (`$folder_ids:networking`) | Standalone `.tfvars`]\n- **Regions (`context.locations`):** `<key-1>` = `<region-1>` [, `<key-2>` = `<region-2>`, ...]\n\n#### 1. Proposed Projects, VPCs & Regional IPAM Allocation (Pre-Filled Defaults)\n| Project Key (`projects/`) | VPC Key (`vpcs/`) | Region (`$locations:`) | Subnet / Range Name | Proposed CIDR | Purpose & Stage 2 Plumbing |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| `net-core-0` | `main` (or `hub` / `routing`) | `<key-1>` | `<subnet-name>` | `10.64.0.0/24` | [Primary subnet / HA VPN / Cloud NAT / DNS] |\n| *(Additional rows for secondary regions, `proxy_only`, `psc`, `psa_configs`, GKE `pods`/`services`, and spoke VPCs as applicable)* | | | | | |\n\n#### 2. Routing, BGP, DNS & Firewall Summary\n- **East-West & Hybrid Routing:** [1–2 bullets including proposed Cloud Router / Peer BGP ASNs if VPN/Interconnect/NCC-RA is used]\n- **Cloud DNS & Firewall Posture:** [Proposed private/forwarding/peering DNS domains (`gcp.example.com.`, `onprem.example.com.`), RPZ, and Hierarchical/VPC Firewall rules]\n- **Downstream Stage Handoffs (Out of Scope for Stage 2):** [List any requested ALBs, Cloud NGFW Enterprise endpoints/profiles, GKE clusters, or Cloud SQL instances that will consume the Stage 2 plumbing in follow-up stages, or `None`]\n```\n\nAsk the user to confirm this unified blueprint (or tell you any CIDRs, ASNs, domain names, Interconnect details, or output path they want changed) before executing **Phase 3 (Dataset Generation & Validation)**.\n","isInternal":false,"tokens":6004,"sizeBytes":24465},{"name":"2-routing-and-security-mechanisms.md","path":"skills/fast/networking/references/2-routing-and-security-mechanisms.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/networking/references/2-routing-and-security-mechanisms.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 2 Reference: Routing and Security Mechanisms Across Topologies\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Once the baseline topology is selected in `1-requirements-to-topology.md`, use this guide to explain and configure how North-South, East-West, Hybrid, Egress, DNS, and Inspection mechanisms work in YAML.\n\n---\n\n## 1. Cross-Topology Mechanism Matrix\n\n| Mechanism | `Single Shared VPC` *(Simple / PoC / Single-IAM)* | `hub-and-spokes-ncc` *(Multi-VPC Cloud-Native Default)* | `hub-and-spokes-nva-ncc` *(Multi-Region NVA Default)* | `hub-and-spokes-nva` *(Single-Region ILB Sandwich)* | `hub-and-spokes-peerings` *(Small Static Estate)* | `hub-and-spokes-vpns` *(Legacy / Niche)* |\n| :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n| **Spoke-to-Hub Routing** | N/A (single VPC `vpcs/main/`; no hub or peering needed). | `ncc_config` on hub & spokes (`hub: $ncc_hubs:hub`). | Dual NCC hubs (`routing` `MESH` + `transit` `STAR`); spokes attach to `transit` `edge` group. | Direct multi-NIC NVA attachment across `dmz`, `hub`, `dev`, `prod` + static routes to `$addresses:<vpc>/main` (no VPC peering). | Reciprocal `peering_config` on both hub & spoke (`routes_config.export/import: true`, enabled by default). | `vpcs/<vpc>/vpns/*.yaml` with reciprocal `$vpn_gateways:` + BGP. |\n| **East-West / Spoke Routing** | **Native intra-VPC routing**; isolated via Firewall Policies, Secure Tags, or Cloud NGFW. | **Direct** (`MESH` preset) or **Blocked at routing layer** (`STAR` `edge` group). | **Inspected via NVA:** Spokes in `transit/edge` route through NVAs in `transit/center` via BGP origination (`advertise` + `routes`). | **Inspected via NVA:** Spoke static routes point inter-spoke prefixes to the local spoke ILB VIP (`$addresses:<spoke>/main`). | **Blocked by design** (VPC Peering is strictly non-transitive). | Filtered via Cloud Router BGP `route_policies` (`IMPORT`/`EXPORT`) in `hub`. |\n| **On-Prem Hybrid Transit** | Terminates directly in `main` (`vpcs/main/vpns/` or `vlan-attachments/`); zero transitivity hops. | Terminates in `hub`; `ncc_spoke_config` on VPN/VLAN attaches tunnels/VLANs as NCC hybrid spokes. | Terminates in `routing` VPC on `routing` `MESH` hub; NVA inspects between `routing` and `transit` hubs. | Terminates in `dmz` or `hub` VPC; static routes steer traffic through NVA ILBs to spokes. | Terminates in `hub`; spokes import custom routes via peering (`routes_config.import: true`). | Terminates in `hub`; re-advertised to spokes over inter-VPC BGP. |\n| **Internet Egress** | `nat_config` (Cloud NAT) and PGA on `main`. | Per-VPC `nat_config` (Cloud NAT) and Private Google Access (`PGA`). | Per-VPC `nat_config` or centralized `0.0.0.0/0` originated on `routing` CR $\\rightarrow$ NVA (`masquerade_exclude`) $\\rightarrow$ Cloud NAT. | Per-VPC `nat_config` or centralized default route via NVA to `dmz`/`untrusted` NIC (`masquerade: true`) + Cloud NAT. | Per-VPC `nat_config` (Cloud NAT) and PGA. | Per-VPC `nat_config` or default route over hub VPN. |\n| **Firewall & Inspection** | Hierarchical policy (`goto_next`) + VPC rules + Secure Tags (+ optional Cloud NGFW or PBR). | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Stateful 3rd-party NVA inspection via BGP RA spokes (active-passive MED or active-active ECMP) + VPC/Hierarchical rules. | Stateful 3rd-party NVA inspection via ILB next-hop (`PER_CONNECTION`, `NONE` affinity, `NEVER_PERSIST`) + VPC/Hierarchical rules. | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Hierarchical policy + VPC firewall rules + BGP prefix filtering. |\n| **Cloud DNS Pattern** | All `private` and `forwarding` zones bind directly to `client_networks: [$networks:main]` (no DNS peering needed). | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub` + RPZ. | Per-spoke private zones bound to spoke + `routing`; `routing` hosts on-prem forwarding; `transit` & spokes peer `.` to `routing` + RPZ. | Per-spoke private zones bound to spoke + `hub`; `dmz` & spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; spokes peer `.` to `hub`. |\n\n---\n\n## 2. Cloud-Native Routing Mechanics (`single-vpc`, `ncc` & `peerings`)\n\n### 2.0 Single Shared VPC (`single-vpc`)\n- **Dataset Structure:** Derived by simplifying `hub-and-spokes-peerings` to a single host project (`projects/net-core-0.yaml` with `shared_vpc_host_config.enabled: true`) and a single VPC (`vpcs/main/.config.yaml`). Omit `ncc-hubs/`, `nvas/`, `peering_config`, and `ncc_config`.\n- **Environment Segmentation without Multiple VPCs:**\n  - Create either general-purpose regional subnets or per-environment regional subnets (e.g., `vpcs/main/subnets/dev-ew8.yaml`, `prod-ew8.yaml`) inside `vpcs/main/subnets/`, delegating subnet-level `roles/compute.networkUser` IAM to downstream service projects.\n  - Enforce East-West isolation between environments or tiers using Hierarchical/VPC Firewall Policies, IAM-governed Secure Tags (`$tag_values:`), and optional Cloud NGFW Enterprise (`$security_profile_groups:`).\n- **Direct Hybrid, PSA & DNS Simplicity:**\n  - HA VPNs (`vpcs/main/vpns/`), VLAN attachments (`vpcs/main/vlan-attachments/`), and Private Services Access (`psa_configs`) all attach directly to `main`, eliminating inter-VPC transitivity constraints.\n  - All Cloud DNS `private` and `forwarding` zones in `dns/zones/` bind directly to `client_networks: [$networks:main]`, and `dns/response-policies/*.yaml` lists only `networks: [$networks:main]`—do **not** create a `peer-root.yaml` DNS peering zone in a Single VPC dataset.\n\n### 2.1 Network Connectivity Center (`hub-and-spokes-ncc`)\n- **`MESH` vs. `STAR` Preset Topology (`ncc-hubs/<hub>.yaml`):**\n  - `preset_topology: MESH` (default) places all spokes in `groups.default`, enabling direct line-rate routing between every spoke (microsegmented via Firewall Policies, Secure Tags, or Cloud NGFW Enterprise).\n  - `preset_topology: STAR` creates two groups (`center` and `edge`). Attach shared-service/transit VPCs to `$ncc_groups:<hub>/center` and workload environment VPCs (`dev`, `prod`) to `$ncc_groups:<hub>/edge` when you want routing-table isolation between workload spokes while sharing central services.\n  - *Constraint:* `preset_topology` is immutable after hub creation.\n- **PSC Propagation:** `export_psc` defaults to `true` in `factory-ncc.tf` so Private Service Connect consumer endpoints created in one spoke (such as `hub`) are automatically reachable from all connected VPC spokes.\n- **NCC Hybrid Spokes (`vpns/*.yaml` & `vlan-attachments/*.yaml`):**\n  - Adding `ncc_spoke_config: { hub: $ncc_hubs:hub }` to a VPN or VLAN attachment file registers it as an NCC hybrid spoke (`factory-ncc.tf` automatically configures `include_import_ranges = [\"ALL_IPV4_RANGES\"]` so Cloud Routers import dynamic routes from the hub and advertise spoke subnets back to on-premises peers).\n  - **`site_to_site_data_transfer` (`true` default vs. `false`):** In `factory-ncc.tf`, `vpn.schema.json`, and `vlan-attachments.schema.json`, `site_to_site_data_transfer` **defaults to `true`**, allowing two hybrid spokes attached to the hub (for example, an On-Premises VPN/Interconnect and a colocated Partner Interconnect) to exchange routes and traffic directly through Google Cloud. Explicitly set `site_to_site_data_transfer: false` when external sites only communicate with GCP VPCs or when deploying in a GCP region that does not support NCC site-to-site data transfer.\n\n### 2.2 VPC Network Peering (`hub-and-spokes-peerings`)\n- **Reciprocal Declaration:** Peering must be declared in **both** VPCs' `.config.yaml` files (`hub` $\\rightarrow$ `prod` and `prod` $\\rightarrow$ `hub`).\n- **Custom Route Exchange:** `factory-peering.tf` defaults `routes_config.export` and `routes_config.import` to `true` so spokes automatically import on-premises prefixes learned by `hub` over VPN/Interconnect.\n- **Non-Transitivity Traps:**\n  1. Workloads in `dev` cannot reach `prod` over `hub` (and if they later need to exchange anonymized data or run shared agents across both environments, peering cannot route it without an intermediate proxy/NVA or migrating to NCC).\n  2. On-premises networks terminating in `hub` cannot reach Private Services Access (`psa_configs` / `servicenetworking`) peered to `dev` or `prod`.\n  3. Cloud DNS outbound forwarding packets (`35.199.192.0/19`) cannot traverse a VPC peering link to reach a VPN/Interconnect in another VPC—always host the forwarding zone in the VPC that terminates the hybrid link (and use DNS peering from spokes).\n\n---\n\n## 3. 3rd-Party NVA Routing & Failover Mechanics (`nva-ncc` vs. `nva`)\n\n### 3.1 Multi-Region NVA via NCC Router Appliance (`hub-and-spokes-nva-ncc`)\nWhenever 3rd-party stateful NVA VMs are used in a **multi-regional architecture** (or alongside PSA managed services), recommend `hub-and-spokes-nva-ncc`:\n\n```text\n  [On-Prem VPN/VLAN] ──> ( routing VPC ) <──BGP── [ NVA nic0 ]\n                              │                       │ (Stateful Inspection)\n                        (routing MESH hub)            ▼\n                                                [ NVA nic1 ] ──BGP──> ( transit VPC )\n                                                                            │\n                                                                 (transit STAR hub: center)\n                                                                            │\n                                                                 (transit STAR hub: edge)\n                                                                      ├── ( dev VPC )\n                                                                      └── ( prod VPC )\n```\n\n1. **Dual-Hub Architecture:**\n   - **`routing` Hub (`MESH`):** Connects hybrid spokes (HA VPN / VLAN attachments in the `routing` VPC) and `nic0` of each regional NVA as a Router Appliance spoke.\n   - **`transit` Hub (`STAR`):** Connects `nic1` of each regional NVA to the `center` group (`$ncc_groups:transit/center`) and workload spoke VPCs (`dev`, `prod`) to the `edge` group (`$ncc_groups:transit/edge`).\n   - *Critical Rule:* **Never host workloads in the `transit` VPC.** The `transit` VPC is the routing VPC for the `transit` RA spokes and learns spoke subnet routes directly from the hub; a VM inside `transit` would send traffic to a spoke via the NVA, while return traffic from the spoke would bypass the NVA and go directly to the `transit` subnet (asymmetric drop).\n2. **`routes` (Data Plane) vs. `advertise` (Control Plane) in `nvas/*.yaml`:**\n   - `routes` installs static Linux kernel routes inside the NVA guest OS via that NIC's subnet gateway so the OS knows which interface forwards to a prefix.\n   - `advertise` originates prefixes in FRR via BGP `network` statements toward Cloud Router peers (used on `nic1` in `transit` so edge spokes—which do not exchange subnet routes with each other in a `STAR` hub—learn routes to other spokes through the NVA).\n   - *Invariant:* Because FRR prefers its own locally originated `network` statement (`weight 32768`) over eBGP routes learned from Cloud Router (`weight 0`), **every prefix listed in `advertise` must be covered by a kernel route in `routes`** (or the default route on a `masquerade` NIC), otherwise the NVA kernel will lack a forwarding entry and blackhole the traffic.\n3. **Active-Passive (No Session Sync) vs. Active-Active ECMP:**\n   - **Active-Passive + Regional Affinity (Default in `hub-and-spokes-nva-ncc`):** Configure explicit per-instance BGP peers on the Cloud Routers using `$nva_addresses:<nva>-<zone>-<index>/<vpc>` and `$nva_instances:<nva>-<zone>-<index>`, attaching distinct `IMPORT` route policies (`med.set(100)` for primary zone `b`, `med.set(150)` for standby zone `c`, and `med.set(10100)` / `10150` for remote-region prefixes). This guarantees symmetric forward and return paths through the same regional primary NVA without requiring cross-instance state synchronization, while failing over automatically within the region or across regions if an appliance or region fails.\n   - **Active-Active ECMP:** Configure a fan-out peer (`nva: <nva-key>`) on the Cloud Router interfaces sharing a single import policy. Both zonal instances advertise at equal MED; use only when the NVA performs stateless inspection or runs active-active state synchronization (e.g., FGSP) and SNAT is excluded for private ranges (`masquerade_exclude`).\n4. **Why `NCC-RA` Wins for Managed Services (`PSA`):**\n   - Managed service producer networks (`servicenetworking` for Cloud SQL, AlloyDB, Memorystore, NetApp) do not have GCE network tags and cannot use network-tagged static routes. Because `NCC-RA` injects dynamic BGP routes into the VPC route table with regional MED priorities, PSA-peered services automatically use the local region's healthy NVA and fail over to the secondary region dynamically.\n\n### 3.2 Single-Region ILB Sandwich (`hub-and-spokes-nva`) & Hybrid `NCC-RA` + `ILB` Chaining\n- **When to use `ilb_config`:** Single-region NVA deployments (`hub-and-spokes-nva`, where `dmz`, `hub`, `dev`, and `prod` are all attached directly to the NVA's 4 NICs without VPC Peering), or when attaching a 3rd NIC (`nic2`) of an `NCC-RA` NVA to a dedicated DMZ or cross-environment bridge VPC using Internal Passthrough NLB next-hop static routes (`$addresses:<vpc>/<nva-name>`).\n- **Mandatory Conntrack & Health Check Guardrails:**\n  - The stage's `factory-nva.tf` configures a single shared health check and defaults `ilb_config.connection_tracking` to `track_per_session: false` (`PER_CONNECTION` 5-tuple tracking) and `persist_conn_on_unhealthy: NEVER_PERSIST` (with `session_affinity: NONE` and 600s idle timeout). Never override `ilb_config.connection_tracking` with `track_per_session: true` (`PER_SESSION` 2-tuple hashing causes asymmetric forward/return selection across NVA instances during health flaps).\n- **Intra-VPC Hairpinning with Policy-Based Routing (PBR):**\n  - If traffic *within* a single VPC (or entering a VPC from a specific source CIDR) must be forced through an NVA ILB, configure `policy_based_routes` in `vpcs/<vpc>/.config.yaml` pointing `next_hop_ilb_ip` to `$addresses:<vpc>/<nva-name>`.\n  - *PBR Guardrail:* Always include a higher-priority (lower numeric `priority` value) rule with `use_default_routing: true` matching Private Service Connect / Google API ranges first so wildcard PBR (`0.0.0.0/0`) does not hijack local PSC/PGA traffic into the NVA.\n\n---\n\n## 4. Firewall Policies & Cloud NGFW Enterprise\n\n1. **Hierarchical & Network Firewall Policies (`firewall-policies/*.yaml`):**\n   - Both Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>` or `organizations/<id>`) and Network Firewall Policies (`parent_id: $project_ids:<key>` with `region: global` or `$locations:<key>`) are defined in `firewall-policies/*.yaml` with an `attachments:` map inside the policy YAML file.\n   - Rules in `modules/net-firewall-policy` default to `action: allow` (ingress) or `action: deny` (egress) if `action` is omitted; always set `action: goto_next` explicitly on rules that should delegate evaluation to lower-level policies or VPC rules.\n   - Supports symbolic CIDR sets (`$cidr_ranges_sets:healthchecks`, `$cidr_ranges_sets:rfc1918`) defined in `defaults.yaml`.\n2. **VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n   - **Rule Key Uniqueness per Project:** GCE firewall rule names are project-scoped. If two VPCs share the same `project_id` (for example, `routing` and `transit` both living in `$project_ids:net-core-0`), their firewall rule keys across `vpcs/routing/firewall-rules/` and `vpcs/transit/firewall-rules/` **must be distinct** (e.g., `allow-routing-ssh` vs. `allow-transit-ssh`) to avoid HTTP 409 conflicts.\n   - **ICMP for PMTUD on VPN VPCs:** Any VPC terminating or transiting HA VPN tunnels must allow `icmp` ingress (especially `ICMP Type 3 Code 4` Fragmentation Needed) so Path MTU Discovery adjusts TCP MSS for in-flight connections across the `1406`/`1460`-byte IPsec tunnel MTU.\n3. **Cloud NGFW Enterprise Integration (L7 IPS / TLS Inspection):**\n   - In `firewall-policies/*.yaml`, rules can set `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>` (where `var.security_profile_groups` is passed from a `2-networking-ngfw` or security stage).\n\n---\n\n## 5. Cloud DNS & Name Resolution Mechanics\n\n1. **Private & Hybrid DNS Topologies (`dns/zones/`):**\n   There are three main patterns for structuring private and forwarding zones across hub and spoke VPCs:\n   - **Pattern A — Per-Environment Zones Bound to Hub + Root Peering (Recommended Default, used in the FAST reference datasets):**\n     1. Each environment manages its own private zone in its own spoke project (e.g., `dns/zones/net-dev-0/pvt-dev.yaml` with `project_id: $project_ids:net-dev-0`) and uses **cross-project VPC binding** (`private.client_networks: [$networks:hub, $networks:dev]`) to bind the zone to both the spoke VPC and the centralized hub VPC (`hub` or `routing`).\n     2. The centralized hub VPC—which **must** be the VPC where hybrid VPN/Interconnect connectivity terminates so Cloud DNS forwarding packets (`35.199.192.0/19`) have a direct path to on-premises resolvers—hosts the on-premises forwarding zone (`forwarding.client_networks: [$networks:hub]`) and top-level shared private zones.\n     3. All spoke VPCs attach to a root peering zone (`domain: .`, `peering.peer_network: $networks:hub`, `client_networks: [$networks:dev, $networks:prod]`)—or, alternatively, bind the forwarding zone directly across VPCs when routing allows. Root peering to the centralized VPC is **preferred** because spokes automatically resolve on-premises domains and all environment zones bound to the hub.\n   - **Pattern B — Fully Centralized Zones + Root Peering:**\n     - All private zones and on-premises forwarding zones are defined centrally in `net-core-0` and bound to the centralized hub VPC (`$networks:hub` or `$networks:routing`), while every spoke VPC peers root (`domain: .`) to the centralized VPC.\n   - **Pattern C — Cross-Project Bindings Only (`client_networks`):**\n     - Private zones (defined either centrally in `net-core-0` or per spoke project) are bound directly to all consumer VPCs via `private.client_networks: [$networks:hub, $networks:dev, $networks:prod]` (requires cross-project IAM permissions, which Stage 2 manages when all projects are in the dataset).\n2. **Cloud DNS Response Policies (`dns/response-policies/*.yaml`):**\n   - Used to steer Google APIs (`*.googleapis.com.`, `*.gcr.io.`, `*.pkg.dev.`) to `private.googleapis.com.` (`199.36.153.8/30`), `restricted.googleapis.com.` (`199.36.153.4/30`), or a custom PSC Google API endpoint IP.\n   - **Regional Endpoint (`*.rep.googleapis.com.`) Bypass:** Cloud DNS Response Policies match by **most-specific DNS suffix** (there is no `priority` attribute in `dns-response-policy-rules.schema.json`). When workloads use GCP Regional Endpoints (`<service>.<region>.rep.googleapis.com.`), include a rule for `dns_name: \"*.rep.googleapis.com.\"` with `behavior: bypassResponsePolicy`—because `*.rep.googleapis.com.` is more specific than `*.googleapis.com.`, it automatically bypasses the wildcard CNAME override so regional TLS certificate validation succeeds.\n   - **PSC Interfaces (`PSC-I`) & Root / `googleapis.com.` Peering:** If a spoke VPC uses PSC Interfaces (`network_attachments` for Vertex AI Agent Engine with VPC-scoped DNS), note that DNS Peering takes precedence over local private zones; avoid peering `googleapis.com.` (or use Pattern C cross-project bindings + RPZ on that spoke) so local PSC-I resolution is not overridden.\n","isInternal":false,"tokens":5155,"sizeBytes":19945},{"name":"3-stage-boundaries-and-plumbing.md","path":"skills/fast/networking/references/3-stage-boundaries-and-plumbing.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/networking/references/3-stage-boundaries-and-plumbing.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 2 Reference: Stage 2 Boundaries & Downstream Plumbing Contract\n\n`fast/stages/2-networking` is a **foundational network plumbing stage**. It creates the projects, VPCs, subnets, IP reservations, routers, interconnects/VPNs, NCC hubs/spokes, base NVAs, DNS zones, and firewall policies that downstream stages consume.\n\nWhen a user states a requirement for a security capability, load balancer, container platform, or managed database, **never** invent unsupported YAML keys inside `2-networking` and **never** tell the user FAST cannot support it. Instead:\n1. Configure the **Stage 2 network plumbing** required by that service in the YAML dataset.\n2. Explain what is delegated to the **follow-up stage** (`1-vpcsc`, `2-networking-ngfw` / security stage, `2-project-factory`, or workload stages).\n3. Document every delegated item in the generated dataset's `README.md` under a **Downstream Stage Handoffs** section.\n\n---\n\n## 1. Stage Boundary Principle & Representative Plumbing Matrix\n\n**General Boundary Principle:** Any GCP service or security capability whose data-plane or control-plane depends on shared VPC primitives (subnets, secondary ranges, special-purpose subnets, IP reservations, PSA peerings, PSC network attachments, routes, DNS zones/RPZ, or firewall policy rules) has those **foundational network primitives provisioned in `2-networking`** (per `fast/stages/2-networking/schemas/*.schema.json`), while the **service, endpoint, or workload resource itself is provisioned in a follow-up stage**.\n\nThe table below shows representative examples of how common capabilities split across `2-networking` and downstream stages:\n\n| Requested Capability | What `2-networking` Provisions (Stage 2 YAML Plumbing) | What is Delegated to Other Stages (Out of Scope for `2-networking`) |\n| :--- | :--- | :--- |\n| **Cloud NGFW Enterprise** *(L7 IPS, Threat Prevention, TLS Inspection)* | • Hierarchical or network firewall policy rules in `firewall-policies/*.yaml` setting `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>`.<br/>• Context mapping via `var.security_profile_groups` in `.tfvars`. | • `google_network_security_firewall_endpoint`, `firewall_endpoint_association`, and `security_profile_group` resources (provisioned in a dedicated **`2-networking-ngfw` / security stage**). |\n| **Application Load Balancers** *(Internal L7 ALB, Regional External ALB, Service Extensions)* | • Regional proxy-only subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `proxy_only: true`, `/23` recommended, `/26` GCP minimum per region/VPC).<br/>• Static VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal`, `external`, or `global`).<br/>• Firewall rules allowing health-check probers (`$cidr_ranges_sets:healthchecks`: `35.191.0.0/16`, `130.211.0.0/22`) and proxy subnet CIDRs. | • Load balancer forwarding rules, target HTTP(S) proxies, URL maps, backend services, NEGs, and Cloud Armor policies (`modules/net-lb-app-*`, deployed in **workload stages**). |\n| **Internal Passthrough NLBs** *(Application-Tier L4 ILBs)* | • Workload subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`).<br/>• Static internal VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal:`).<br/>• Health-check allow rules (`35.191.0.0/16`, `130.211.0.0/22`). | • Application-tier `modules/net-lb-int` resources (deployed in **workload stages**). *(Note: NVA transit ILBs ARE managed directly by `2-networking` via `nvas/*.yaml` `ilb_config`).* |\n| **GKE Clusters** *(Standard & Autopilot)* | • Shared VPC host enablement (`shared_vpc_host_config.enabled: true`) and `container.googleapis.com` API in `projects/net-*.yaml`.<br/>• Primary node subnets with `secondary_ip_ranges` (`pods` and `services`) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• Optional firewall rules for private control-plane webhooks/master CIDRs. | • Service project creation and Shared VPC subnet-level IAM attachment (`roles/compute.networkUser`, `roles/container.hostServiceAgentUser` in **`2-project-factory`**).<br/>• GKE cluster & nodepool provisioning (`modules/gke-*` in **workload stages**). |\n| **Private Service Connect (PSC) — Producer** *(Publishing Services Across VPCs/Orgs)* | • Dedicated PSC NAT subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `psc: true`, `/24` recommended per service attachment).<br/>• Regional `proxy_only: true` subnet if the published producer LB is an Internal Regional ALB. | • Producer load balancer and `google_compute_service_attachment` (`modules/net-lb-*` in **producer workload stages**). |\n| **Private Service Connect (PSC) — Consumer** *(Consuming SaaS, Managed DBs, or Google APIs)* | • Consumer PSC endpoint IPs in `vpcs/<vpc>/addresses.yaml` under `psc:` (`subnet_self_link` + `service_attachment: { psc_service_attachment_link: ... }` for service attachments, or `global: true` + `target: all-apis\\|vpc-sc` for Google API bundles).<br/>• Private Cloud DNS zones/records in `dns/zones/` or RPZ rules in `dns/response-policies/`.<br/>• `export_psc: true` on `ncc-hubs/*.yaml` to propagate consumer endpoints across NCC spokes. | • Producer-side project allowlisting (`consumer_accept_lists`) if the producer is external. |\n| **Private Services Access (PSA)** *(Cloud SQL, AlloyDB, Memorystore, NetApp Volumes)* | • `servicenetworking.googleapis.com` API in `projects/net-*.yaml`.<br/>• `psa_configs` in `vpcs/<vpc>/.config.yaml` (allocating `/24`–`/16` internal CIDR ranges, `export_routes: true`, `import_routes: true`).<br/>• Cloud Router `custom_advertise.ip_ranges` if on-premises networks need to reach the PSA ranges.<br/>• Note: If multiple NCC spokes need transitive access to a PSA peering (`servicenetworking-googleapis-com` or `sn-netapp-prod`), document attaching an **NCC Producer VPC Spoke** (`linked_producer_vpc_network`) or using PSC endpoints instead. | • Managed database or storage instances (`modules/cloudsql-*`, `alloydb`, `netapp-volumes` in **workload stages**). |\n| **Serverless & Vertex AI Runtimes** *(Cloud Run Direct VPC Egress, Vertex AI Agent Engine PSC-I)* | • Dedicated subnets (`>= /26`, `/24` recommended to account for `2x` rollout IP reservation) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• `network_attachments` in `vpcs/<vpc>/.config.yaml` for PSC Interfaces (`PSC-I`, referencing `<resolved-region>/<subnet-name>` and `automatic_connection`).<br/>• Cloud DNS RPZ bypass (`*.rep.googleapis.com.` `bypassResponsePolicy`) and ensuring `googleapis.com.` is **not** in `dns/zones/` as a DNS Peering zone on VPCs using `PSC-I`. | • Service project attachment in **`2-project-factory`**.<br/>• Cloud Run services, Vertex AI endpoints, and Agent Engine deployments in **workload stages**. |\n| **VPC Service Controls (VPC-SC)** *(Data Exfiltration Perimeters)* | • Plumbing for `restricted.googleapis.com` (`199.36.153.4/30`) routes and DNS response policies (`dns/response-policies/*.yaml`).<br/>• Optional `$vpc_sc_perimeters:` mapping on networking host projects (`var.perimeters`). | • Access Context Manager policies, access levels, and regular/bridge service perimeters (managed in **`1-vpcsc`**). |\n\n---\n\n## 2. Example YAML Plumbing Snippets for `2-networking`\n\nThe snippets below are **illustrative examples** showing how Stage 2 plumbing is expressed against the `fast/stages/2-networking/schemas/*.schema.json` schemas. Adapt names, keys, regions, and CIDRs to the user's design, and consult the JSON schemas in `fast/stages/2-networking/schemas/` for the full set of supported attributes. *(Note: when grouping subnets in regional folders under `vpcs/<vpc>/subnets/<region>/`, ensure every `.yaml` file within a VPC has a **unique filename stem**, e.g. `prod-proxy-ew1.yaml` and `prod-proxy-ew8.yaml`, because `modules/net-vpc` keys raw subnet files by basename).*\n\n### 2.1 Regional ALB Proxy-Only & PSC Producer Subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-proxy-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-proxy-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.128.0/23\ndescription: Regional Managed Proxy subnet for Internal and Regional External ALBs\nproxy_only: true\nactive: true\n```\n\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-psc-nat-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-psc-nat-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.130.0/24\ndescription: PSC NAT subnet for publishing Service Attachments\npsc: true\n```\n\n### 2.2 GKE Node Subnet with Pod & Service Secondaries (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-gke-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-gke-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.0.0/22\ndescription: Primary subnet for GKE nodes with secondary Pod and Service ranges\nsecondary_ip_ranges:\n  pods:\n    ip_cidr_range: 10.104.0.0/17\n  services:\n    ip_cidr_range: 10.108.0.0/21\n```\n\n### 2.3 PSA Ranges & PSC Interfaces (`network_attachments`) in `vpcs/<vpc>/.config.yaml`\n```yaml\n# Inside vpcs/prod/.config.yaml\npsa_configs:\n  - ranges:\n      cloudsql-psa: 10.110.0.0/20\n    export_routes: true\n    import_routes: true\n\nnetwork_attachments:\n  agent-engine-ew1:\n    # Must be '<resolved-region>/<subnet-name>' matching a subnet in this VPC:\n    subnet: europe-west1/prod-psc-int-ew1\n    automatic_connection: false\n    # Literal GCP project IDs (producer_accept_lists does not interpolate $project_ids):\n    producer_accept_lists:\n      - my-ai-workload-prod-0\n```\n\n### 2.4 Reserved VIPs & PSC Consumer Endpoints (`vpcs/<vpc>/addresses.yaml`)\n```yaml\n# vpcs/prod/addresses.yaml\n# yaml-language-server: $schema=../../../schemas/addresses.schema.json\n\ninternal:\n  # Referenced in routes/PBR as $addresses:prod/app-ilb-ew1\n  app-ilb-ew1:\n    region: $locations:ew1\n    subnetwork: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.10\n\npsc:\n  alloydb-psc-ew1:\n    region: $locations:ew1\n    subnet_self_link: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.50\n    service_attachment:\n      psc_service_attachment_link: projects/producer-prj/regions/europe-west1/serviceAttachments/alloydb-sa\n```\n\n### 2.5 Cloud NGFW Enterprise Rule in `firewall-policies/*.yaml`\n```yaml\n# Inside firewall-policies/networking-policy.yaml\ningress_rules:\n  inspect-east-west:\n    priority: 1000\n    action: apply_security_profile_group\n    security_profile_group: $security_profile_groups:ips-profile-group\n    description: Delegate L7 IPS inspection to Cloud NGFW Enterprise\n    match:\n      source_ranges:\n        - $cidr_ranges_sets:rfc1918\n      destination_ranges:\n        - $cidr_ranges_sets:rfc1918\n      layer4_configs:\n        - protocol: all\n```\n\n---\n\n## 3. Handoff Outputs Contract for Downstream Stages\n\nWhen `2-networking` runs `terraform apply`, it emits stage outputs (`host_project_ids`, `vpc_self_links`, `subnet_self_links`, `subnet_proxy_only_self_links`, `subnet_psc_self_links`, and `2-networking.auto.tfvars.json`) consumed by `2-project-factory` and workload stages.\n\nAlways include a **Downstream Stage Handoffs** table in the generated dataset's `README.md` listing:\n1. Which **Shared VPC Host Projects** (`$project_ids:<key>`) and **Subnets** (`<vpc>/<region>/<subnet>`) downstream service projects should attach to in `2-project-factory`.\n2. Which **`proxy_only`**, **`psc`**, **`psa_configs`**, or **`network_attachments`** resources were pre-created in `2-networking` and which workload stage / CFF module (`gke-cluster-*`, `net-lb-app-*`, `cloudsql-*`) will consume them.\n3. Any external context variables (`var.security_profile_groups`, `var.folder_ids`, `var.kms_keys`, `var.perimeters`) that must be passed into `2-networking.auto.tfvars` when running `terraform apply`.\n","isInternal":false,"tokens":3172,"sizeBytes":11899},{"name":"4-dataset-schema-and-tokens.md","path":"skills/fast/networking/references/4-dataset-schema-and-tokens.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/networking/references/4-dataset-schema-and-tokens.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 2 & 3 Reference: Dataset Directory Layout, Schemas & `$token` Rules\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)** and **Phase 3 (Dataset Generation & Validation)**. Consult the live JSON schemas in `fast/stages/2-networking/schemas/*.schema.json` alongside this guide when generating or modifying YAML files.\n\n---\n\n## 1. Canonical Dataset Directory Hierarchy & Schema Mapping\n\nEvery YAML file in a `2-networking` dataset must sit at one of the canonical factory paths below and begin with a `# yaml-language-server: $schema=...` header pointing to the corresponding schema in `fast/stages/2-networking/schemas/`:\n\n| Dataset-Relative Path | JSON Schema (`fast/stages/2-networking/schemas/`) | Factory & Purpose |\n| :--- | :--- | :--- |\n| `defaults.yaml` | `defaults.schema.json` | Stage defaults (`vpcs.defaults`, `projects.defaults`), `output_files`, and `context` maps (`locations`, `cidr_ranges`, `cidr_ranges_sets`, `email_addresses`, `iam_principals`, etc.). |\n| `folders/**/.config.yaml` | `folder.schema.json` | Optional folder hierarchy definitions (up to 4 levels) under the networking branch. |\n| `projects/**/*.yaml` or `folders/**/*.yaml` *(except `.config.yaml`)* | `project.schema.json` | Networking host/transit project definitions (`name`, `parent`, `services`, `shared_vpc_host_config`, `project_reuse`). Projects placed under `folders/<dir>/<proj>.yaml` automatically inherit `parent: $folder_ids:<dir>`. |\n| `ncc-hubs/*.yaml` | `ncc-hub.schema.json` | NCC Hub (`name`, `project_id`, `preset_topology: MESH\\|STAR`, `export_psc`, `groups`). |\n| `nvas/*.yaml` | `nva.schema.json` | Multi-NIC NVA clusters (`auto_instance_config.nics`, `instance_groups`, `ilb_config`, `ncc_ra_config`, `bgp_config`). |\n| `firewall-policies/**/*.yaml` | `firewall-policy.schema.json` | Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>`) and Global/Regional Network Firewall Policies (`parent_id: $project_ids:<key>`, `region: global` or `$locations:<key>`, `attachments: { <vpc>: $networks:<vpc> }`). |\n| `dns/zones/**/*.yaml` | `dns.schema.json` | Cloud DNS zones (`private`, `forwarding`, `peering`, `public`, `recordsets`, `delegations`). |\n| `dns/response-policies/**/*.yaml` | `dns-response-policy-rules.schema.json` | Cloud DNS Response Policy zones and rules (`local_data`, `behavior: bypassResponsePolicy`). |\n| `vpcs/<vpc-key>/.config.yaml` | `vpc.schema.json` | VPC configuration (`name`, `project_id`, `mtu`, `routing_mode`, `nat_config`, `ncc_config`, `peering_config`, `psa_configs`, `network_attachments`, `routers`, `routes`, `policy_based_routes`, `dns_policy`, `factories_config`). |\n| `vpcs/<vpc-key>/addresses.yaml` | `addresses.schema.json` | Reserved addresses in `<vpc-key>` (`internal`, `external`, `global`, `psc`, `psa`). |\n| `vpcs/<vpc-key>/firewall-rules/**/*.yaml` | `firewall-rules.schema.json` | VPC-level `ingress` and `egress` firewall rules. |\n| `vpcs/<vpc-key>/subnets/**/*.yaml` | `subnet.schema.json` | Subnet definitions (`name`, `region`, `ip_cidr_range`, `secondary_ip_ranges`, `proxy_only`, `psc`, `private_nat`, `iam`). |\n| `vpcs/<vpc-key>/vpns/**/*.yaml` | `vpn.schema.json` | HA VPN gateways (`name`, `region`, `peer_gateways`, `router_config`, `tunnels`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/vlan-attachments/**/*.yaml` | `vlan-attachments.schema.json` | Dedicated/Partner Interconnect VLAN attachments (`name`, `region`, `peer_asn`, `router_config`, `dedicated_config` / `partner_config`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/attachment-groups/**/*.yaml` | `attachment-groups.schema.json` | Interconnect attachment groups for SLA redundancy. |\n\n---\n\n## 2. Symbolic Context (`$token`) Namespace & Key Derivation Rules\n\nFAST `2-networking` resolves `$namespace:key` tokens across factories. Never hardcode GCP resource self-links when a context token exists.\n\n### 2.1 Resources Created Inside the Dataset\nPay close attention to whether a token key comes from the **directory/file stem** or the **`name:` attribute** inside the YAML file:\n\n| Token Syntax | Key Derivation Rule | Resolved Value & Where Used |\n| :--- | :--- | :--- |\n| `$project_ids:<project-key>` | **File stem** of `projects/**/<project-key>.yaml` or `folders/**/<project-key>.yaml` (e.g., `projects/net-core-0.yaml` $\\rightarrow$ `$project_ids:net-core-0`). | Project ID. Used in `project_id:` across VPCs, DNS zones, NCC hubs, NVAs, and `ncc-hubs/*.yaml` `auto_accept`. |\n| `$folder_ids:<folder-key>` | **Relative directory path** under `folders/` (or passed from Stage 0 `var.folder_ids`, e.g., `$folder_ids:networking`). | Folder ID (`folders/123456`). Used in `projects/*.yaml` `parent:` and `firewall-policies/*.yaml` `parent_id:` / `attachments:`. |\n| `$networks:<vpc-key>` | **Directory path** under `vpcs/<vpc-key>/.config.yaml` (e.g., `vpcs/prod/.config.yaml` with `name: prod-0` is referenced as **`$networks:prod`**, NOT `prod-0`). | VPC self-link (or name in VPNs). Used in `peering_config`, `dns/zones/*.yaml` `client_networks` & `peer_network`, `firewall-policies/*.yaml` `attachments`, and `nvas/*.yaml` `nics[].network`, `ilb_config.forwarding_rules[].network`, & `ncc_ra_config.spokes[].network`. |\n| `$subnets:<vpc-key>/<region>/<subnet-name>` | `<vpc-key>` (directory under `vpcs/`) + **resolved region name** (e.g. `europe-west1`, NOT the `$locations:` alias) + **`name:` attribute** inside the subnet YAML. *(Resolves regular and `private_nat` subnets only, not `proxy_only` or `psc` subnets).* | Subnet self-link. Used in `addresses.yaml` (`subnetwork` / `subnet_self_link`), `nvas/*.yaml` (`nics[].subnet`), Cloud NAT `subnetworks`, and Cloud Router `interfaces[].subnet`. |\n| `$ncc_hubs:<hub-name>` | Required **`name:` attribute** inside `ncc-hubs/<file>.yaml`. | NCC Hub ID. Used in `vpcs/<vpc>/.config.yaml` `ncc_config.hub`, `vpns`/`vlan-attachments` `ncc_spoke_config.hub`, and `nvas/*.yaml` `ncc_ra_config.spokes[].hub`. |\n| `$ncc_groups:<hub-name>/<group-key>` | `<hub-name>` (`name:` in `ncc-hubs/*.yaml`) + map key under `groups:` (e.g., `$ncc_groups:hub/default` or `$ncc_groups:transit/center`). | NCC Group ID. Used in `ncc_config.group`, `ncc_spoke_config.group`, and `ncc_ra_config.spokes[].group`. |\n| `$routers:<vpc-key>/<router-key>` | `<vpc-key>` (directory under `vpcs/`) + map key under `routers:` in `vpcs/<vpc-key>/.config.yaml`. | Cloud Router name. Used in `vpns/*.yaml` and `vlan-attachments/*.yaml` under `router_config.name` (with `create: false`). |\n| `$vpn_gateways:<vpc-key>/<vpn-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vpns/<file>.yaml` (e.g., `vpcs/hub/vpns/onprem.yaml` with `name: to-onprem` $\\rightarrow$ `$vpn_gateways:hub/to-onprem`). | HA VPN Gateway ID. Used in `peer_gateways.<key>.gcp` for GCP-to-GCP HA VPNs. |\n| `$vlan_attachments:<vpc-key>/<att-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vlan-attachments/<file>.yaml`. | VLAN Attachment ID. Used in `vpcs/<vpc-key>/attachment-groups/*.yaml` `attachments:`. |\n| `$attachment_groups:<vpc-key>/<grp-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/attachment-groups/<file>.yaml`. | Interconnect Attachment Group ID. Used in `vpcs/<vpc-key>/vlan-attachments/*.yaml` `attachment_group:`. |\n| `$addresses:<vpc-key>/<addr-key>` | `<vpc-key>` + map key under `internal:` in `vpcs/<vpc-key>/addresses.yaml` (e.g., `internal: { app-ilb: ... }` in `vpcs/prod/addresses.yaml` $\\rightarrow$ **`$addresses:prod/app-ilb`**, with NO `/internal/` segment). | Reserved internal IP address. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop`) and `policy_based_routes` (`next_hop_ilb_ip`). |\n| `$addresses:<vpc-key>/<nva-name>` | `<vpc-key>` (from `ilb_config.forwarding_rules[].network`) + **`name:` attribute** of the NVA in `nvas/<file>.yaml`. | NVA Internal Passthrough NLB VIP in `<vpc-key>`. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop_type: ilb`, `next_hop:`) and `policy_based_routes` (`next_hop_ilb_ip:`). |\n| `$nva_instances:<nva-name>-<zone>-<index>` | `<nva-name>` (`name:` in `nvas/*.yaml`) + `<zone>` key in `instance_groups:` + `0`-based instance index (for `auto_create_instances > 0`, e.g., `$nva_instances:primary-b-0`). | NVA VM instance self-link. Used in explicit Cloud Router `peers[].router_appliance_instance` (`vpcs/<vpc>/.config.yaml`) and `ncc_ra_config.spokes[].instances[].vm`. |\n| `$nva_addresses:<nva-name>-<zone>-<index>/<vpc-key>` | Instance key (`<nva-name>-<zone>-<index>`) + `<vpc-key>` of the attached NIC (e.g., `$nva_addresses:primary-b-0/routing`). | NVA VM NIC internal IP in `<vpc-key>`. Used in explicit Cloud Router `peers[].address` and `ncc_ra_config.spokes[].instances[].ip`. |\n\n### 2.2 Stage Context & External Variables (`defaults.yaml` & `var.context`)\n- **`$locations:<key>`:** Defined under `context.locations` in `defaults.yaml` (e.g., `ew1: europe-west1`, `ew8: europe-west8`). Supported across subnets, routers, Cloud NAT, NVAs, VPNs, VLAN attachments, firewall policies, addresses, routes, and `policy_based_routes`.\n- **`$cidr_ranges:<key>` & `$cidr_ranges_sets:<key>`:** Defined under `context.cidr_ranges` and `context.cidr_ranges_sets` in `defaults.yaml`.\n  - `$cidr_ranges_sets:<key>` is resolved in **both** `firewall-policies/*.yaml` and VPC `vpcs/<vpc>/firewall-rules/*.yaml`.\n  - `$cidr_ranges:<key>` is resolved in `vpcs/<vpc>/.config.yaml` `routes` and `policy_based_routes`, as well as subnet configs and `firewall-rules/*.yaml`.\n- **`$custom_roles:<key>`, `$iam_principals:<key>`, `$email_addresses:<key>`, `$kms_keys:<key>`, `$tag_keys:<key>`, `$tag_values:<key>`, `$vpc_sc_perimeters:<key>`:** Defined in `defaults.yaml` `context` (except `folder_ids`, which comes from `var.folder_ids` or `var.context.folder_ids`) or passed from root stage variables (`var.custom_roles`, `var.iam_principals`, `var.service_accounts`, `var.kms_keys`, `var.tag_keys`, `var.tag_values`, `var.perimeters`).\n- **`$security_profile_groups:<key>`:** Passed via `var.security_profile_groups` (or `defaults.yaml` `context.security_profile_groups`). Used in `firewall-policies/*.yaml` rules with `action: apply_security_profile_group`.\n\n### 2.3 Common Schema & Factory Gotchas\n- **Unique Subnet Filename Stems per VPC (`vpcs/<vpc>/subnets/`):** `modules/net-vpc` keys raw subnet YAML files by `basename(file)` (stripping any `<region>/` subdirectory). Never create two subnet files with the same filename in different regional folders of the same VPC (e.g., `subnets/ew1/default.yaml` and `subnets/ew8/default.yaml` will collide); always include the region or tier in the filename (`subnets/ew1/prod-default-ew1.yaml`).\n- **Subnet `secondary_ip_ranges` (`subnet.schema.json`):** Each secondary range entry under `secondary_ip_ranges:` must be an **object** with `ip_cidr_range` (or `reserved_internal_range`), never a bare CIDR string:\n  ```yaml\n  secondary_ip_ranges:\n    pods:\n      ip_cidr_range: 10.104.0.0/17\n    services:\n      ip_cidr_range: 10.108.0.0/21\n  ```\n- **VPC `network_attachments` (`vpc.schema.json`):** Under `network_attachments.<key>`, `subnet` must be `<resolved-region>/<subnet-name>` (e.g., `europe-west1/prod-psc-int-ew1`), the connection toggle is `automatic_connection: true|false` (not `automatic_accept`), and `producer_accept_lists` / `producer_reject_lists` take literal project IDs (they do not interpolate `$project_ids:`).\n- **Disabling VLAN Attachments (`vpcs/<vpc>/.config.yaml`):** To disable loading `vlan-attachments/` for a VPC (such as in the reference datasets), set `factories_config: { vlan_attachments: \"none\" }` inside `vpcs/<vpc>/.config.yaml`—there is no `vlan_attachments` key under `var.factories_config.paths` in `.tfvars`.\n- **Firewall Policies (`firewall-policies/*.yaml`) vs. VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n  - `firewall-policies/*.yaml` (`firewall-policy.schema.json`) uses `ingress_rules` / `egress_rules`, `action: allow|deny|goto_next|apply_security_profile_group`, `match.source_ranges`, `match.destination_ranges`, and `match.layer4_configs: [{ protocol: tcp, ports: [\"22\"] }]`.\n  - `vpcs/<vpc>/firewall-rules/*.yaml` (`firewall-rules.schema.json`) uses `ingress` / `egress` maps where each rule defines top-level `source_ranges` / `destination_ranges`, `rules: [{ protocol: tcp, ports: [\"22\"] }]` (with `deny: false` for allow or `deny: true` for deny), and `enable_logging: { include_metadata: false }` (an object, not a boolean).\n\n---\n\n## 3. Generating `2-networking.auto.tfvars`\n\nAlways generate a companion `2-networking.auto.tfvars` file next to (or pointing to) the custom dataset directory:\n\n### 3.1 When Deployed on Top of FAST `0-org-setup`\n`0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` already supply `billing_account`, `organization`, `prefix`, `folder_ids`, `service_accounts`, and `storage_buckets`. The `.tfvars` file only needs `factories_config`:\n\n```hcl\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n### 3.2 When Deployed Standalone (Without `0-org-setup`)\nProvide the required FAST root variables (`billing_account`, `organization`, `prefix`) and any referenced `$folder_ids` or `$project_ids` (if reusing existing projects). In `defaults.yaml`, either omit `output_files.storage_bucket` (or use `output_files.local_path`) or pass `storage_buckets` in `.tfvars` so `apply` does not attempt to write to an unresolved `$storage_buckets:iac-0/iac-outputs` string:\n\n```hcl\nbilling_account = {\n  id = \"012345-6789AB-CDEF01\"\n}\n\norganization = {\n  domain      = \"example.com\"\n  id          = 123456789012\n  customer_id = \"C01234567\"\n}\n\nprefix = \"myorg\"\n\nfolder_ids = {\n  networking = \"folders/1234567890\"\n}\n\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n---\n\n## 4. Phase 3 Execution: Validation & `terraform plan`\n\nAfter writing the YAML files into the user's custom dataset directory:\n\n1. **Run the Bundled Validator Script (Mandatory Gate):**\n   ```bash\n   uv run skills/fast/networking/scripts/validate_dataset.py <path/to/custom-dataset>\n   ```\n   - This script validates:\n     1. Every YAML file against its JSON schema in `fast/stages/2-networking/schemas/`.\n     2. All `$token` references, strict CIDR syntax (no host bits set), intra-VPC and cross-VPC CIDR overlaps, unique subnet file basenames per VPC, reciprocal VPC peerings, unique firewall rule names per project, and NVA (`ilb_config` / `ncc_ra_config` / BGP / `routes` vs. `advertise`) guardrails.\n     3. Isolated `terraform init -backend=false`, `terraform validate`, and `terraform plan -refresh=false` in a temporary directory using synthetic FAST variables.\n   - Fix any reported errors or unintended warnings and re-run until `validate_dataset.py` exits with code `0`.\n\n2. **Write `<custom-dataset>/README.md`:**\n   Include a concise `README.md` in the custom dataset folder summarizing:\n   - Topology overview and regional IPAM & VPC summary table.\n   - **Downstream Stage Handoffs** (any requested ALBs, Cloud NGFW Enterprise security profile groups, GKE secondary ranges, PSC service attachments, or PSA databases that follow-up stages will deploy on top of this plumbing).\n","isInternal":false,"tokens":4265,"sizeBytes":15181},{"name":"SKILL.md","path":"skills/fast/networking/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/networking/SKILL.md","title":"fast-networking","category":"anthropic-skill","format":"markdown","content":"---\nname: fast-networking\ndescription: Designs, customizes, and validates Cloud Foundation Fabric FAST Stage 2 (2-networking) YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a 2-networking dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a 2-networking dataset.\n---\n\n# FAST `2-networking` Dataset Designer\n\nThis skill guides users from functional networking and security requirements—or an attached reference diagram/IP table—to a complete, schema-validated Cloud Foundation Fabric FAST Stage 2 (`fast/stages/2-networking`) YAML dataset in a custom directory.\n\n## Core Principles & Execution Rules\n\n> [!IMPORTANT]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):**\n> For all design and customization tasks, prepend the following 3-phase progress block to **every** response so the user always sees the current phase and active step (if the user only asks to validate an existing dataset on disk, run `scripts/validate_dataset.py` directly and report the results):\n> ```text\n> FAST 2-Networking Designer Progress:\n> - Phase 1: Requirements Discovery\n>   (Step 1/2: Baseline & VPC Footprint - IN PROGRESS)\n> - Phase 2: Topology, IPAM & Plumbing Proposal\n>   (Not started)\n> - Phase 3: Dataset Generation & Validation\n>   (Not started)\n> ```\n\n> [!IMPORTANT]\n> **Understanding Turn Boundaries & Conditional Questioning (`ask_user` / `ask_question` / `question`):**\n> - Once you ask the user a question or present a specification for confirmation, **stop execution immediately (do not call more tools)**.\n> - Never simulate, guess, or assume the user's answer in the same turn. Wait for the user's next message.\n> - **NEVER ask a meta-question about how the user wants to start:** Never ask whether the user wants an interview vs. diagram import vs. cloning a dataset. If the user did not attach a diagram or name an existing dataset in their prompt, start **Turn 1** of the interview immediately (you may mention in one line of prose that they can share a diagram or dataset name at any time).\n> - **NEVER bundle dependent questions in the same turn or question tool call:** Only mutually **independent** questions may be asked together. You **MUST** resolve whether the user wants a **Single Shared VPC** vs. **Multi-VPC Hub & Spokes** *before* asking any questions about spoke-to-spoke routing, NCC hubs, peering, or inter-VPC transit NVAs.\n> - **Concrete Options Only:** Every multiple-choice option must be self-contained and actionable. Never offer a vague option like *\"Single region (e.g., europe-west8 or europe-west1)\"* that forces an extra follow-up turn just to ask *which* single region they meant; list the specific common region choices directly and allow custom free-text input.\n\n1. **Step-by-Step by Default, Adaptive on Rich Input:**\n   - **Default Mode (Conditional Interview DAG):** When the user starts with a general request (e.g., *\"Help me define my FAST networking dataset\"*), follow the Conditional Interview DAG in `references/1-requirements-to-topology.md`, asking only the questions that apply to the user's chosen branch (`2` turns for Single Shared VPC; `3` turns for Multi-VPC Hub & Spokes) and recommending sensible FAST defaults.\n   - **Adaptive Mode (Diagram, IP Table, or Detailed Spec Provided):** When the user provides an architecture diagram (PNG/SVG/PDF/Draw.io), an IP table, or a detailed prompt upfront, extract all stated facts immediately, mark **Phase 1** completed, jump straight to **Phase 2 (Topology, IPAM & Plumbing Proposal)** with pre-filled defaults for any unspecified CIDRs/ASNs, and ask for a single sign-off before generating files.\n   - **Validation-Only Mode:** When the user points to an existing dataset directory and asks to validate or test it, run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the findings.\n   - **Zero-Redundancy Rule:** Never ask the user to repeat a region, CIDR, environment name, connectivity choice, or workload type they already provided in their prompt or diagram.\n\n2. **Stage 2 Boundary (\"Plumbing vs. Downstream Consumption\"):**\n   - `fast/stages/2-networking` provisions **foundational network plumbing only**: networking host projects, VPCs, subnets (primary, secondary, `proxy_only`, `psc`, `private_nat`), `psa_configs`, reserved addresses, Cloud Routers, Cloud NAT, VPC Peering, NCC hubs/spokes, HA VPNs, Interconnect VLAN attachments, NVA instances/ILBs/BGP spokes, Cloud DNS zones/response policies, and hierarchical/network/VPC firewall policies.\n   - Higher-level security and application resources—such as **Cloud NGFW Enterprise** (`firewall_endpoint` and `security_profile_group`, typically managed in a `2-networking-ngfw` or security stage), **Application Load Balancers** (`net-lb-app-*`), **GKE clusters**, **Cloud SQL / AlloyDB / NetApp instances**, and **PSC Producer `ServiceAttachment`s**—are deployed in **follow-up stages** (`2-project-factory` or workload/security stages).\n   - Whenever the user requests one of these capabilities, configure the required Stage 2 plumbing (e.g., `/23` `proxy_only: true` subnets, `psc: true` subnets, `psa_configs`, `$security_profile_groups:` rules in `firewall-policies/`) and record the downstream handoff in the generated dataset's `README.md`. Never invent unsupported YAML keys inside `2-networking`.\n\n3. **Custom Dataset Output Target:**\n   - Never overwrite the in-tree reference datasets under `fast/stages/2-networking/datasets/` unless explicitly developing/updating Fabric itself.\n   - Always generate the user's dataset into a dedicated custom directory (e.g., `./fast-config/2-networking/data/` or a user-specified path) along with a ready-to-use `2-networking.auto.tfvars` file that maps `factories_config` to that directory.\n\n4. **Mandatory Mechanical Validation Gate:**\n   - Never declare a generated or modified dataset complete until you have run `scripts/validate_dataset.py` against the target directory (checking JSON schemas, `$token` reference integrity, intra-VPC and cross-VPC CIDR overlaps, and routing/security guardrails, plus `terraform validate` and `terraform plan` when Terraform is available).\n\n---\n\n## Workflow Map\n\nBefore executing a phase, read its corresponding reference document for the decision tables, YAML patterns, and guardrails.\n\n### Phase 1: Requirements Discovery\n*Description:* Walk the Conditional Interview DAG (or extract facts from an attached diagram/prompt), forking cleanly between **Single Shared VPC** and **Multi-VPC Hub & Spokes**.\\\n*Reference: [Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- **Step 1:** Stage 0 Integration, Concrete Target Regions & Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`)\n- **Step 2:** Branch-Specific Requirements Discovery (`Step 2` Single-VPC Plumbing on Path A; `Step 2a` Spoke Scope/Inspection + `Step 2b` Hybrid/Egress/Plumbing on Path B)\n\n### Phase 2: Topology, IPAM & Plumbing Proposal (Single Sign-Off Gate)\n*Description:* Map the functional requirements to a **Single Shared VPC** layout or one of the canonical `2-networking` hub-and-spoke topologies (`ncc`, `nva-ncc`, `nva`, `peerings`, `vpns`), pre-fill sensible non-overlapping default CIDRs, BGP ASNs, DNS zones, and Stage 2 plumbing in a single unified blueprint table, and ask the user once to confirm (or tweak) the proposal and output directory.\\\n*References:*\n- *[Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- *[Routing and Security Mechanisms](references/2-routing-and-security-mechanisms.md)*\n- *[Stage Boundaries and Plumbing](references/3-stage-boundaries-and-plumbing.md)*\n- *[Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 3:** Unified Topology, Pre-Filled Regional IPAM/BGP/DNS Defaults, Output Path & Downstream Handoff Sign-Off\n\n### Phase 3: Dataset Generation & Validation\n*Description:* Generate the YAML dataset and `2-networking.auto.tfvars` in the user's custom output directory, run `scripts/validate_dataset.py` (`terraform validate` and `terraform plan`), and write the dataset `README.md`.\\\n*Reference: [Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 4:** YAML Dataset & `2-networking.auto.tfvars` Generation\n- **Step 5:** Mechanical Validation (`scripts/validate_dataset.py`, including `terraform validate` and `terraform plan`)\n- **Step 6:** Dataset `README.md` & Follow-up Stage Handoff Summary\n","frontmatter":{"name":"fast-networking","description":"Designs, customizes, and validates Cloud Foundation Fabric FAST Stage 2 (2-networking) YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a 2-networking dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a 2-networking dataset."},"isInternal":false,"tokens":2023,"sizeBytes":8647},{"name":"phase1-env-and-auth.md","path":"skills/fast/prerequisites/references/phase1-env-and-auth.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/references/phase1-env-and-auth.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 1: Environment & Authentication\n\n### Step 1: Environment Assessment & Initialization\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your very first Turn 1 response prepends the progress block showing:\n>   - Phase 1: Environment & Authentication (Step 1/2: Target Environment Selection - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n>\n> **MANDATORY START POINT (TURN 1):** \n> - You MUST begin the entire workflow by asking the user to clarify their target environment (**Standard GCP** or **Google Cloud Dedicated (GCD)**). \n> - You **MUST NOT** run any `gcloud` commands, check active credentials, or proceed to Step 2 or any other Phase/Step in Turn 1. \n> - You **MUST** stop execution immediately after asking this question and wait for the user's response.\n> - Do NOT assume, pre-fill, or guess the environment based on local config files, active credentials, or background command success.\n>\n> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) in a subsequent turn and wait for their response.\n\n1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.**\n2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.**\n3. *If GCD is selected*, ask the user if they are working in one of the known universes: **S3NS (France)** or **Berlin (Germany)**.\n   - *If S3NS:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.s3nscloud.fr`\n     - Universe API Domain: `s3nsapis.fr`\n     - Universe Name: `s3ns`\n     - Universe Prefix: `s3ns`\n     - Universe Region: `u-france-east1`\n   - *If Berlin:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.berlin-build0.goog`\n     - Universe API Domain: `apis-berlin-build0.goog`\n     - Universe Name: `berlin`\n     - Universe Prefix: `eu0`\n     - Universe Region: `u-germany-northeast1`\n   - *If neither (Custom):* Gather the 5 universe-specific details manually from the user.\n   - *Action:* Present the final list of the 5 universe values to the user for review. Ask for explicit confirmation and offer them the opportunity to change any of the values before proceeding.\n\n### Step 2: Authentication\n\n> [!CRITICAL]\n> **DO NOT START STEP 2 PREMATURELY:** You MUST NOT check authentication, run `gcloud config list`, or execute Step 2 until Step 1 (Environment Assessment & Initialization) has been completely finished and confirmed by the user in previous turns.\n\n1. Ask the user if they are already authenticated with Google Cloud using the correct principal.\n   - *If yes:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use.\n     - *If they confirm (and Step 1 is already completed):* Proceed directly to Phase 2 (Step 3).\n     - *If they do not confirm:* Proceed with the authentication steps below.\n   - *If no:* Proceed with the authentication steps below.\n2. *Standard GCP:* Provide or execute the command:\n   ```bash\n   gcloud auth login\n   gcloud auth application-default login\n   ```\n3. *GCD:* Automate or guide the user through WIF login. Ask for the workforce pool audience string first, then generate the configuration:\n   ```bash\n   # (Use the gathered GCD variables to fill placeholders)\n   gcloud config configurations create <UNIVERSE_NAME>\n   gcloud config configurations activate <UNIVERSE_NAME>\n   gcloud config set universe_domain <UNIVERSE_API_DOMAIN>\n\n   gcloud iam workforce-pools create-login-config <AUDIENCE> \\\n     --universe-cloud-web-domain=\"<UNIVERSE_WEB_DOMAIN>\" \\\n     --universe-domain=\"<UNIVERSE_API_DOMAIN>\" \\\n     --output-file=\"/tmp/wif-login-config-<UNIVERSE_NAME>.json\" \\\n     --activate\n\n   gcloud auth login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json --no-launch-browser\n   gcloud auth application-default login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json\n   ```\n4. Explicitly ask the user to confirm they have successfully authenticated before moving to the next phase.\n","isInternal":false,"tokens":1052,"sizeBytes":4391},{"name":"phase2-admin-and-baseline.md","path":"skills/fast/prerequisites/references/phase2-admin-and-baseline.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/references/phase2-admin-and-baseline.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 2: Admin Principal & Baseline Info\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your responses in Phase 2 prepend the progress block showing:\n>   - Phase 1: Environment & Authentication (2/2 steps completed)\n>   - Phase 2: Admin Principal & Baseline Info (Step 1/2: Admin Principal Definition - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n\n### Step 3: Admin Principal Definition\n\n1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter.\n2. Determine the Admin Principal approach by asking the user to choose between two options:\n   - **Approach A (Preferred): Use a pre-created Group.**\n     - *Action:* Explain that using a group (e.g., `group:gcp-organization-admins@example.com`) is the standard and preferred way. **Crucially, clarify that the group provided MUST be a group that the user's current authenticated identity belongs to**, otherwise they will lock themselves out.\n     - *Action:* Ask the user to provide the group email address.\n     - *Action:* Explicitly ask the user to confirm that their current identity (the one they just authenticated with) is already a member of this group.\n     - *Action:* If the user answers \"No\" to the membership confirmation, **DO NOT PROCEED**. Inform the user that proceeding will lock them out. Ask them to either authenticate with an identity that *is* a member of the group (and restart the authentication step), or provide a different group that their current identity belongs to.\n   - **Approach B (Fallback): Use a Single User.**\n     - *Action:* Explain that this flow uses a single user as the sole GCP Org Admin, but more can be added later.\n     - *Action:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve their current authenticated principal.\n     - *Action:* Show the user their current principal and explicitly ask them to confirm this is the identity they want to use as the Admin Principal.\n\n### Step 4: Baseline Information Gathering\n\n1. Gather baseline information required for `0-org-setup`:\n   - Organization ID (and the associated Directory Customer ID, which is important for Standard GCP but not required for GCD)\n   - Billing Account ID (Mandatory for subsequent stages, even if not required for the GCD temporary project)\n   *Action:* When prompting the user for the Organization ID and Billing Account ID, explicitly instruct them in the prompt/question that they can leave the field blank (or type \"list\") to have you automatically run the relevant `gcloud` command (`gcloud organizations list --format=\"json\"` or `gcloud beta billing accounts list`). Also, instruct them that they can type a keyword to filter the list.\n   *Action:* If the user leaves the field blank or types \"list\", run the `gcloud` command without filters. If the user types a keyword that is not a valid Organization ID (numeric) or Billing Account ID (e.g., `012345-6789AB-CDEF01`), run the `gcloud` command and use that keyword to filter the results using a **case-insensitive** regex match. Ensure the regex pattern is enclosed in single quotes within the filter argument (e.g., `--filter=\"displayName~'(?i)KEYWORD'\"`). Do NOT use `*` wildcards in the filter.\n   *Action:* If the filtered `gcloud` command returns no results, inform the user and ask if they want to provide a different keyword or fetch all items (run without a filter).\n   *Action:* Once you have results for Organizations (filtered or unfiltered), extract the Organization ID, the Display Name (domain), and the Directory Customer ID (found in `owner.directoryCustomerId` in the JSON output). Sort them alphabetically by the display name, and then present the results for the user to make their selection. Note the Organization ID, Domain, and Customer ID for Phase 4. For Billing Accounts, do the same sorting and selection.\n2. Determine the **Admin Principal's** access level to the provided Billing Account ID. Ask which of the following three scenarios applies to the Admin Principal (not necessarily the current user):\n   - **Scenario 1 (Billing Administrator):** The Admin Principal has `roles/billing.admin`.\n     - *Action:* Ask a follow-up question: \"Is your billing account managed by the same organization where we are installing FAST, or outside of it? (You can check this in the Google Cloud Console by going to Billing -> using the organization picker on top -> checking if the account is listed under this organization).\"\n     - *If Inside the Org:* Note that `roles/billing.admin` WILL be assigned at the Organization level in Step 6. Instruct the user that we will deactivate the billing factories path for now, but if account-level IAM also needs to be managed via FAST later, they can reactivate the path and use the billing YAML to do it.\n     - *If Outside the Org:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6.\n   - **Scenario 2 (Billing User):** The Admin Principal has `roles/billing.user` but NOT admin rights.\n     - *Action:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6. Either disable the billing YAML via the `factories_config` variable or comment it out, since the Admin Principal cannot control IAM on the account.\n     - *Action:* **Explain to the user:** The service accounts for IaC (and therefore the provider switch and subsequent stages, except for VPC-SC) will not be operative until the correct billing permissions have been assigned to them outside of FAST.\n   - **Scenario 3 (No Access):** The Admin Principal has absolutely no rights on the billing account.\n     - *Action:* **Clearly state:** This scenario is mostly used for development purposes, is strongly discouraged, and requires advanced Terraform skills and FAST knowledge to proceed.\n","isInternal":false,"tokens":1307,"sizeBytes":5982},{"name":"phase3-bootstrap-and-iam.md","path":"skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 3: Bootstrap Project & IAM\n\n### Step 5: IAM Role Assignments\n\n1. Grant the following roles to the chosen Admin Principal at the Organization level. **CRITICAL:** Only include `roles/billing.admin` in this list if the user selected Scenario 1 (Billing Administrator) AND confirmed the billing account is managed Inside the Organization in Step 4.\n   **CRITICAL EXECUTION RULE:** Do NOT create temporary bash scripts (e.g., `assign_roles.sh`) to execute this loop. You MUST execute the `for` loop inline directly using the `run_shell_command` tool, or output the exact inline loop for the user to copy/paste.\n   ```bash\n   # Roles to assign:\n   # [roles/billing.admin] <-- CONDITIONAL (See above)\n   # roles/logging.admin\n   # roles/iam.organizationRoleAdmin\n   # roles/orgpolicy.policyAdmin\n   # roles/resourcemanager.folderAdmin\n   # roles/resourcemanager.organizationAdmin\n   # roles/resourcemanager.projectCreator\n   # roles/resourcemanager.tagAdmin\n   # roles/owner\n\n   # Loop example for the user or tool execution:\n   for role in [ROLES_LIST]; do\n     gcloud organizations add-iam-policy-binding <ORG_ID> \\\n       --member=\"<ADMIN_PRINCIPAL>\" --role=\"$role\" --condition=None\n   done\n   ```\n\n### Step 6: Bootstrap Project Setup\n\n1. Explain that a temporary bootstrap project is required to track API quota before organization policies are fully established.\n2. Ask the user if they already have a suitable project they can use for this purpose.\n   - *If yes:* Ask if this project is already configured as the active project in `gcloud`. If the user does not know, run `gcloud config list project --format=\"value(core.project)\"` to check for them.\n     - If it is already configured, fetch the Project ID using `gcloud config list project --format=\"value(core.project)\"`. Explicitly ask the user to confirm if this fetched Project ID is the one they want to use. **Only if they answer \"No\" to this confirmation**, ask them to provide the correct Project ID.\n     - If it is not configured, ask the user to provide the Project ID.\n   - *If no:* Ask the user to use the Cloud Console to create a temporary project (must be linked to the billing account). Ask them to provide the new Project ID once created.\n3. Once the Project ID is provided or fetched, ensure it is set as the default project. If it is not already set, run:\n   ```bash\n   gcloud config set project <TEMP_PROJECT_ID>\n   ```\n4. Enable the required baseline APIs on the project:\n   - The required APIs are: `bigquery.googleapis.com`, `cloudbilling.googleapis.com`, `cloudresourcemanager.googleapis.com`, `essentialcontacts.googleapis.com`, `iam.googleapis.com`, `logging.googleapis.com`, `orgpolicy.googleapis.com`, `serviceusage.googleapis.com`.\n   - *If the project was pre-existing:* Ask the user if they want you to check which services are already enabled.\n     - *If yes:* Run `gcloud services list --enabled --format=\"value(config.name)\"` to get the current list. Compute the delta between the enabled services and the required list. Only run `gcloud services enable <MISSING_APIS>` for the ones that are missing.\n     - *If no:* Run the full `gcloud services enable` command for all required APIs.\n   - *If the project is new:* Run the full `gcloud services enable` command for all required APIs.\n","isInternal":false,"tokens":755,"sizeBytes":3280},{"name":"phase4-config-and-wrapup.md","path":"skills/fast/prerequisites/references/phase4-config-and-wrapup.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/references/phase4-config-and-wrapup.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Phase 4: Configuration & Wrap-up\n\n### Step 7: Configuration Generation\n\n1. **Explain Datasets:** Briefly explain to the user that FAST uses \"datasets\" (collections of YAML files) that fully describe the design, architecture, resources, and policies applied.\n2. **Select the Dataset:** Ask the user to select the dataset they want to use for their landing zone design.\n   - *If GCD:* Explicitly state that the `classic-gcd` dataset must be used for GCD installations.\n   - *If Standard GCP:* Offer the available datasets and ask them to choose one. **Crucially, only search for available datasets within the `fast/stages/0-org-setup/datasets/` directory.** Do not search across the entire repository or other FAST stages. Provide a one-line description below each dataset when presenting the options (e.g., \"classic: The standard FAST landing zone architecture\", \"hardened: A more restrictive, hardened landing zone architecture\").\n3. **Explain Defaults Configuration:** Explain to the user that we are starting the configuration of the `defaults.yaml` file, which drives the static configuration of the dataset by providing the Org ID, Billing ID, user-specified locations, and any static values they need to bring in from the outside (like additional IAM principals used in the YAML files to assign IAM roles).\n4. **Determine Locations:** FAST uses a set of locations for different services.\n   - *If GCD:* The region is fixed based on the universe selected in Phase 1. Set the `logging` location to `global` and all other required locations to the Universe Region. Do not ask the user to choose; simply show them the configured locations.\n   - *If Standard GCP:* Check the `fast/stages/0-org-setup/schemas/defaults.schema.json` to identify the required location keys (e.g., `bq`, `gcs`, `logging`, `pubsub`). First, ask the user to provide a \"base location\" (e.g., `europe-west1`), explaining that submitting without answering will confirm the default value. Even if the user confirms the base location, you must then ask if they need to override the location for any individual services.\n5. **Determine Local Path:** Explain to the user that FAST generates provider configurations and other files that need to be stored outside the repository. This is defined by the `output_files.local_path` setting. Propose a default path based on the chosen prefix (e.g., `~/fast-config/<prefix>`) and ask the user to confirm or provide a different path. **Crucially, if the user provides a relative path (e.g., `custom-fast-config` or `./custom-fast-config`), use it exactly as provided relative to the current working directory. Do not automatically prepend `~/` to their input.** Once confirmed, create this directory using `mkdir -p <LOCAL_PATH>`.\n6. **Ask for Additional Context:** Ask the user if there are any other static values they want to bring in from outside to be referenced in the YAML files. Show them the available context keys (excluding `condition_vars`). Provide examples showing that prefixes are mandatory for IAM principals (e.g., `user:foo@example.com`, `group:bar@example.com`). For GCD, also show a `principalSet:` example. **Do not use shell commands like `echo` or `cat` to show this list; output it directly in your chat message.**\n7. **Create Local Directories and Copy Defaults:**\n   - Create the `data/0-org-setup/` and `providers/` directories inside the confirmed `local_path` (`mkdir -p <LOCAL_PATH>/data/0-org-setup/ <LOCAL_PATH>/providers/`).\n   - **Copy** (do not move) the `defaults.yaml` from the chosen dataset folder to `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` using `cp`.\n8. **Edit the Copied Defaults File:** Use the `replace` tool to edit the *copied* `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` file.\n   - Populate `global.billing_account`, `global.organization.id`, `global.organization.domain`, and `global.organization.customer_id` (using the values gathered in Phase 2). Note that `customer_id` may not be present for GCD.\n   - Populate `context.iam_principals.gcp-organization-admins` (using the Admin Principal determined in Phase 2).\n   - Populate the service-specific locations gathered in the previous step into the `context.locations` block and map them appropriately in `projects.defaults.locations`.\n   - Populate `output_files.local_path` with the confirmed path.\n   - If additional context was provided, add it to the `context` block.\n   - *If GCD*, ensure the `overrides.universe` block is present with `domain`, `prefix`, and specific identity overrides.\n9. Run YAML validation: `yamllint -c .yamllint --no-warnings <LOCAL_PATH>/data/0-org-setup/defaults.yaml`. Handle missing tool errors as described in the Core Principles.\n10. **Create Configuration Files:**\n    - Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`).\n    - In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `\"datasets/classic\"`), but the `paths.defaults` must point to the absolute path of the copied defaults file.\n    - *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`.\n11. **Present Configuration and Halt:** Briefly tell the user you have created and validated the baseline configuration files. You MUST stop execution immediately, present the generated files, ask the user if they are ready to proceed with **Step 8 (Organization Policy Import Check & Essential Contacts)**, and wait for their response. Do not proceed to Step 8 or run more tools in this turn.\n\n### Step 8: Organization Policy Import Check & Essential Contacts\n\n1. **Organization Policy Import Check:**\n   - Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported.\n   - Provide (or execute if automatic) the command to list current policies:\n     ```bash\n     gcloud org-policies list --organization=\"<ORG_ID>\" --format=\"value(constraint)\"\n     ```\n   - **STOP Execution and Wait:** You MUST stop execution immediately here, ask the user to run the command, and wait for them to paste the output. Do NOT proceed to Step 9 or update any files until the user has explicitly provided the output of this command.\n   - **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them.\n\n2. **Essential Contacts Handling:**\n   - Explain to the user that FAST Stage 0 datasets configure essential contacts as part of the organization configuration, and simultaneously enforce the Essential Contacts domain restriction organization policy (`essentialcontacts.allowedContactDomains`). Any essential contacts defined must match the allowed domains in the organization policy, otherwise Terraform deployment will fail with policy violation errors.\n   - Ask the user to choose how they want to handle essential contacts:\n     - **Option A (Define Essential Contact & Update Org Policy - Recommended):**\n       - Prompt the user for the essential contact email address.\n       - If the currently authenticated identity / deploying principal (from Phase 1/2) is an email-like principal (e.g. `user@example.com` or `user:user@example.com`), offer the option to reuse it.\n       - If the deploying principal is a Workforce Identity Federation (WIF) identity (i.e. starting with `principal://`), skip this option and prompt the user to provide a valid email address.\n       - Update the essential contact in `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` (under `context.email_addresses` or `projects.defaults.contacts`) using the `replace` tool.\n       - Update the organization policy in `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool so that `essentialcontacts.allowedContactDomains` allows the domain part of the provided email (e.g. adding `@<EMAIL_DOMAIN>` to the allowed values).\n     - **Option B (Allow All Domains in Org Policy):**\n       - If the user does not want to restrict domains or wants to bypass domain restrictions, update `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool to set the policy rule to allow all values (`allow: all: true`), commenting out the existing restricted rule setup. Do NOT delete or empty the file (to keep it as a reference and avoid Terraform failures on empty YAML files).\n\n### Step 9: Wrap-up & Apply\n\n1. **Link Configuration:** Instruct the user to link the final configuration files to the stage directory using the `fast-links.sh` script. They should navigate to `fast/stages/0-org-setup/` and run:\n   ```bash\n   ../../fast-links.sh <LOCAL_PATH>\n   ```\n   Explain that they should then copy and run the commands printed by the script to create the links.\n   **Note:** If they are not on GCD, they can ignore the provider file linking command printed by the script for the first run.\n2. Remind the user that the prerequisite phase is complete.\n3. Instruct them to run `terraform init` and `terraform apply`.\n4. Remind them to delete the temporary project and reset their gcloud default project to `iac-0` after a successful apply. Provide the exact commands for them to copy and use later:\n   ```bash\n   gcloud projects delete <TEMP_PROJECT_ID>\n   gcloud config set project <IAC_PROJECT_ID>\n   ```\n","isInternal":false,"tokens":2193,"sizeBytes":9797},{"name":"SKILL.md","path":"skills/fast/prerequisites/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/SKILL.md","title":"fast-prerequisites","category":"anthropic-skill","format":"markdown","content":"---\nname: fast-prerequisites\ndescription: Guides the user step-by-step through the prerequisites for the FAST 0-org-setup stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for 0-org-setup or bootstrap the FAST landing zone.\n---\n\n# FAST 0-org-setup Prerequisites Guide\n\n## Core Principles & Execution Rules\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):** \n> To ensure the human user always knows where they are in the workflow and can immediately spot if you skip steps or make false assumptions, **EVERY SINGLE response you output MUST start with this progress block**. Do not omit it, do not shorten it, and do not skip step sequences.\n>\n> Format to prepend to EVERY message:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (Step 1/2: Target Environment Selection - IN PROGRESS)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Not started)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n>\n> As steps are completed, update the bracketed lines to show completed steps or current active step, e.g.:\n> ```text\n> FAST Prerequisites Progress:\n> - Phase 1: Environment & Authentication\n>   (2/2 steps completed)\n> - Phase 2: Admin Principal & Baseline Info\n>   (Step 1/2: Admin Principal Definition - IN PROGRESS)\n> - Phase 3: Bootstrap Project & IAM\n>   (Not started)\n> - Phase 4: Configuration & Wrap-up\n>   (Not started)\n> ```\n\n> [!CRITICAL]\n> **Understanding Turn Boundaries:** You are running in a turn-based execution environment. \n> - You receive one user message, then you can think and run tools.\n> - Once you decide you need input from the user (e.g. to choose an environment or confirm a principal), you MUST output your question and **STOP execution immediately (do not call more tools)**. \n> - You **CANNOT** receive the user's answer in the same turn. \n> - You **MUST NOT** simulate, guess, or assume the user's response in your subsequent thinking blocks during the same turn. You must wait for the next turn to receive the actual input.\n>\n> **Interactive Questions (`ask_user` / `ask_question`):** Use the `ask_user` (or `ask_question`) tool whenever available to prompt the user, especially whenever multiple options or choices are presented.\n>\n> **Do NOT Skip Steps or Make Assumptions:** You MUST NOT skip any phases or steps, even if you think they are redundant or if you find information on the system (like active credentials) that suggests a step is already complete. You MUST execute every step sequentially, in order, and wait for explicit user input/confirmation at each step boundary.\n>\n> **MANDATORY START POINT (TURN 1):** You MUST explicitly ask the user to choose their target environment (Standard GCP or GCD) in your first turn. Do NOT check active credentials or run background commands to bypass this step. The environment selection is a fundamental gateway that dictates all downstream resources, variables, and configurations. Proceeding without explicitly getting this choice in Turn 1 is a critical failure.\n>\n> **Strictly One Question at a Time:** You MUST NOT bundle multiple questions or steps together in a single response. Ask exactly one question, wait for the user's answer, and only then proceed to the next question or action.\n>\n> **Sandbox Awareness:** You are running inside an isolated, sandboxed temporary workspace (e.g., `/tmp/gemini_harness_*`). Whenever creating local files, configuration directories (like `custom-fast-config` or `fast-config`), or checking defaults, you MUST do so strictly relative to your current workspace directory (CWD). NEVER try to directly read or write to `/home/ludomagno/` or other external folders, as your file tools are sandboxed and will fail with permission errors.\n>\n> **Step-by-Step Execution:** Never implement a single \"magical\" flow. Go through each step one at a time, explaining context and asking for confirmation.\n3. **Execution Choice:** Respect the user's execution preference (automatic via `run_command` vs. manual copy/paste) throughout the entire workflow unless the user explicitly instructs you to change it. This preference will be gathered during Phase 1.\n4. **File Modifications:** Always use `replace` or `write_file`. **Never** use opaque shell commands (like `sed`, `echo >>`, or `cat <<EOF >>`). Show proposed edits and ask for confirmation before applying them so the user can see what we're doing.\n5. **YAML Validation:** Validate generated YAML using `yamllint -c .yamllint --no-warnings <file>`. If the command is not available, ask the user if they prefer to a) install it, b) have you install it (`pip install yamllint`), or c) skip validation.\n6. **Resuming Mid-Flow:** If the user is resuming a previously interrupted session, ask them which Phase or Step they left off at, or assess the current state by reading previously generated files (e.g., `defaults.yaml`, `0-org-setup.auto.tfvars`). Read the corresponding reference file for the identified phase and resume execution directly from that point.\n\n## Workflow Map\n\nGuide the user through the following sequence strictly in order. **Before starting a phase, read its corresponding reference document to get the exact instructions, commands, and logic.**\n\n### Phase 1: Environment & Authentication\n*Description:* Determine the target environment (Standard GCP or GCD) and ensure the user is properly authenticated.\\\n*Reference: [Environment & Authentication](references/phase1-env-and-auth.md)*\n- **Step 1:** Environment Assessment & Initialization (Standard vs GCD)\n- **Step 2:** Authentication\n\n### Phase 2: Admin Principal & Baseline Info\n*Description:* Define the core administrative identity and gather essential baseline data like Organization and Billing IDs.\\\n*Reference: [Admin Principal & Baseline Info](references/phase2-admin-and-baseline.md)*\n- **Step 3:** Admin Principal Definition (Group vs Single User)\n- **Step 4:** Baseline Information Gathering (Org ID, Billing ID, Billing Access Scenarios)\n\n### Phase 3: Bootstrap Project & IAM\n*Description:* Assign the necessary organization-level IAM roles and set up a temporary project for API quota tracking.\\\n*Reference: [Bootstrap Project & IAM](references/phase3-bootstrap-and-iam.md)*\n- **Step 5:** IAM Role Assignments\n- **Step 6:** Bootstrap Project Setup (Creation and API enablement)\n\n### Phase 4: Configuration & Wrap-up\n*Description:* Generate the FAST dataset configuration, handle existing organization policies and essential contacts, and prepare for the final Terraform apply.\\\n*Reference: [Configuration & Wrap-up](references/phase4-config-and-wrapup.md)*\n- **Step 7:** Configuration Generation (Datasets, defaults.yaml, local paths)\n- **Step 8:** Organization Policy Import Check & Essential Contacts\n- **Step 9:** Wrap-up & Apply\n","frontmatter":{"name":"fast-prerequisites","description":"Guides the user step-by-step through the prerequisites for the FAST 0-org-setup stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for 0-org-setup or bootstrap the FAST landing zone."},"isInternal":false,"tokens":1556,"sizeBytes":6894},{"name":"TESTING.md","path":"skills/fast/prerequisites/TESTING.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/fast/prerequisites/TESTING.md","title":"Prerequisites Skill","category":"anthropic-skill","format":"markdown","content":"# FAST 0-org-setup Prerequisites - Testing Workflows\n\nThis document outlines the various testing workflows and combinations for the `fast-prerequisites` skill to ensure all branches of the skill's logic are covered.\n\n## Core Variables & Decision Points\n\n1. **Environment:** Standard GCP vs. Google Cloud Dedicated (GCD)\n2. **GCD Universe (if applicable):** S3NS (France), Berlin (Germany), or Custom\n3. **Execution Mode:** Auto (Gemini CLI runs commands) vs. Manual (Output commands for user)\n4. **Authentication State:** Already authenticated vs. Needs authentication\n5. **Admin Principal Approach:** Group (Approach A) vs. Single User (Approach B)\n6. **Billing Account Access Level:** Billing Administrator vs. Billing User vs. No Access\n7. **Bootstrap Project:** Pre-existing (with or without API check) vs. New project created by user\n8. **Configuration / Dataset:** Standard GCP datasets vs. `classic-gcd` dataset\n9. **Org Policy Import Check & Essential Contacts:** Pre-existing policies found vs. No policies found; Essential Contacts configured (deploying user vs. custom email) vs. Essential Contacts policy set to allow all\n\n## Comprehensive Test Scenarios\n\n### Scenario 1: Standard GCP - The \"Simple User\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Manual\n* **Authentication:** Needs authentication (Standard auth flow)\n* **Admin Principal:** Single User (current authenticated user)\n* **Information Gathering:** Direct input of Org ID and Billing ID\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** New project (user creates, CLI outputs full API enable command)\n* **Configuration:** Default local path, standard dataset, additional context added\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n### Scenario 2: Standard GCP - The \"Developer\" Flow\n\n* **Environment:** Standard GCP\n* **Execution Mode:** Auto\n* **Authentication:** Already authenticated\n* **Admin Principal:** Group (`foo-owners@example.com`)\n* **Information Gathering:** Uses \"list\" to fetch Org ID and Billing ID\n* **Billing Access:** Billing User (requires disabling billing YAML)\n* **Bootstrap Project:** Pre-existing project (auto-check and enable missing APIs)\n* **Configuration:** Custom local path, standard dataset, no additional context\n* **Org Policies:** Some policies exist (to test tfvars update)\n\n#### Notes\n\n### Scenario 3: GCD (S3NS) - Automated Setup\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** S3NS (France)\n* **Execution Mode:** Auto\n* **Authentication:** Needs authentication (WIF login flow)\n* **Admin Principal:** Group\n* **Billing Access:** Billing Administrator\n* **Bootstrap Project:** Pre-existing project (skip API check, enable all)\n* **Configuration:** `classic-gcd` dataset (fixed), auto-mapped universe regions, providers.tf generated\n* **Org Policies:** No policies exist\n\n### Scenario 4: GCD (Custom) - Manual Setup with Restrictions\n\n* **Environment:** Google Cloud Dedicated (GCD)\n* **Universe:** Custom (manually input all 5 variables)\n* **Execution Mode:** Manual\n* **Authentication:** Already authenticated\n* **Admin Principal:** Single User\n* **Billing Access:** No Access (requires warning and manual continuation)\n* **Bootstrap Project:** New project\n* **Configuration:** `classic-gcd` dataset, custom local path\n* **Org Policies:** Some policies exist\n","isInternal":false,"tokens":742,"sizeBytes":3346},{"name":"SKILL.md","path":"skills/maintenance/release-process/SKILL.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/maintenance/release-process/SKILL.md","title":"release-process","category":"anthropic-skill","format":"markdown","content":"---\nname: release-process\ndescription: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n---\n\n# Cloud Foundation Fabric Release Process\n\nThis skill guides maintainers through the process of cutting a new release for the Cloud Foundation Fabric (CFF) repository.\n\n> **CRITICAL:** Release names and tags MUST ALWAYS be prefixed with `v` (e.g., `v55.1.0`, `v56.0.0`).\n\n## Prerequisites\n\n- **Permissions:** You must have write/release permissions on the repository.\n- **GitHub Token:** A valid GitHub token (can be easily derived by running `gh auth token`).\n- **Environment:** You can run the tools using either `uv` (recommended) or a standard Python virtual environment with `tools/requirements.txt` installed.\n\n## 1. Preparation\n\nStart from the `master` branch and ensure you are up to date:\n\n```bash\ngit checkout master\ngit pull\n```\n\nIdentify the **latest released version** by extracting it from the `default-versions.tf` file:\n\n```bash\nLATEST_RELEASE=$(sed -n 's/^# Fabric release: //p' default-versions.tf)\necho \"Latest release is: $LATEST_RELEASE\"\n```\n\n## 2. Generate and Review Changelog\n\nRun the changelog script, specifying the bump type (e.g., `major`, `minor`, `patch`):\n\n```bash\n# Set your bump type here (major, minor, or patch)\nBUMP_TYPE=\"minor\"\n\n# Option A: Using uv (Recommended)\nuv run tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n\n# Option B: Using a standard virtual environment\n./tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE --write --token $(gh auth token)\n```\n\n**Review the changes:**\nRun `git diff CHANGELOG.md` and carefully inspect the output. Look for:\n\n- PRs missing labels (they will appear under a \"no subsection\" or similar area).\n- Weird or unclear names in PR titles.\n\n**If you find issues:**\n\n1. Click the PR link in the diff.\n2. Manually fix the labels or title on GitHub.\n3. Re-run the `changelog.py` command above.\n4. Repeat until the changelog is clean.\n\n## 3. Bump Versions\n\nOnce the changelog is correct, extract the newly generated release version from the top of the changelog and bump the repository versions:\n\n```bash\nNEW_RELEASE=$(sed -n 's/^## \\[\\([^]]*\\)\\].* - .*/\\1/p' CHANGELOG.md | head -n 1)\n# Ensure it has the 'v' prefix\nif [[ $NEW_RELEASE != v* ]]; then NEW_RELEASE=\"v$NEW_RELEASE\"; fi\necho \"New release is: $NEW_RELEASE\"\n\n# Option A: Using uv (Recommended)\nuv run tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n\n# Option B: Using a standard virtual environment\n./tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\n```\n\n## 4. Commit, Open PR, and Merge\n\nBecause `master` is protected and requires pull requests via the merge queue, create a branch, stage all updated files (`CHANGELOG.md`, `default-versions.tf`, `default-versions.tofu`, `fast_version.txt`, and module `versions.tf`/`versions.tofu` files), and open a PR:\n\n```bash\nBRANCH_NAME=\"$(gh api user -q .login)/prep-$NEW_RELEASE\"\ngit checkout -b \"$BRANCH_NAME\"\ngit add -u\ngit commit -m \"prep $NEW_RELEASE\"\ngit push -u origin \"$BRANCH_NAME\"\n\ngh pr create \\\n  --title \"Prep $NEW_RELEASE\" \\\n  --label \"on:FAST,on:modules,on:tools\" \\\n  --body \"Release preparation for $NEW_RELEASE:\n\n- \\`CHANGELOG.md\\`: generated with \\`tools/changelog.py --release-from $LATEST_RELEASE --bump $BUMP_TYPE\\`\n- version strings bumped to \\`$NEW_RELEASE\\` with \\`tools/versions.py --fabric-release $NEW_RELEASE --write-defaults\\`\n\nNo functional changes; every non-CHANGELOG diff is the release string only.\"\n\n# Queue the PR for auto-merge once approved and checks pass\ngh pr merge --auto\n```\n\nOnce the PR is approved and merged into `master`, switch back to `master`, pull the merged commit, and wait for the GitHub Actions workflows on `master` to finish successfully before proceeding to the next step:\n\n```bash\ngit checkout master\ngit pull\n\n# Watch the latest workflow run on the master branch\ngh run watch $(gh run list --branch master --limit 1 --json databaseId -q '.[0].databaseId')\n```\n\n## 5. Create the GitHub Release\n\nYou can create the release either automatically via the GitHub CLI or manually via the GitHub UI.\n\n### Option A: Automated via GitHub CLI (Recommended)\n\nThis script extracts any \"BREAKING CHANGES\" from the changelog and converts the heading to Title Case.\n\n```bash\n# 1. Extract the section for the new release from CHANGELOG.md\n# We use awk to grab everything from the new release header until the next release header\nRELEASE_NOTES=$(awk \"/^## \\[$NEW_RELEASE\\]/{flag=1; next} /^## \\[v/{flag=0} flag\" CHANGELOG.md)\n\n# 2. Extract just the BREAKING CHANGES section (if any)\nBREAKING_CHANGES=$(echo \"$RELEASE_NOTES\" | awk '/^### BREAKING CHANGES/{flag=1; print; next} /^### /{flag=0} flag')\n\n# 3. Format breaking changes\nif [ -n \"$(echo \"$BREAKING_CHANGES\" | tr -d '[:space:]')\" ]; then\n  # Convert \"### BREAKING CHANGES\" to \"## Breaking Changes\"\n  FORMATTED_BREAKING_CHANGES=$(echo \"$BREAKING_CHANGES\" | sed 's/^### BREAKING CHANGES/## Breaking Changes/')\n  echo \"$FORMATTED_BREAKING_CHANGES\"\nfi\n```\n\n> **CRITICAL:** Show the extracted and formatted breaking changes to the user and wait for their approval before proceeding to create the release.\n\nOnce approved, run the appropriate command to create the release.\n\nIf there were breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes --notes \"$FORMATTED_BREAKING_CHANGES\"\n```\n\nIf there were no breaking changes:\n\n```bash\ngh release create \"$NEW_RELEASE\" --title \"$NEW_RELEASE\" --generate-notes\n```\n\n### Option B: Manual via GitHub UI\n\nGo to the [GitHub Releases UI](https://github.com/GoogleCloudPlatform/cloud-foundation-fabric/releases/new) and configure the release:\n\n1. **Tag:** Create a new tag matching the new version (e.g., `v56.0.0`).\n2. **Title:** Use the exact same version string as the tag.\n3. **Release Notes:** Click the **\"Generate release notes\"** button.\n4. **Breaking Changes:** If the `CHANGELOG.md` contains a \"BREAKING CHANGES\" section for this release, copy it, paste it at the **top** of the generated release notes, and change the heading to \"## Breaking Changes\".\n\nClick **Publish release**.\n","frontmatter":{"name":"release-process","description":"Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release."},"isInternal":false,"tokens":1586,"sizeBytes":6236},{"name":"README.md","path":"skills/README.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/skills/README.md","title":"Skills Skill","category":"anthropic-skill","format":"markdown","content":"# Fabric Skills\n\nThis directory contains skills for AI agents to leverage Cloud Foundation Fabric (CFF). These skills provide context, guidelines, and tools for agents to perform specific tasks related to CFF.\n\n## Installing with `agents-cli`\n\nYou can install the user-facing Fabric skills (`fabric-builder`, `fast-networking`, and `fast-prerequisites`) into your project's `.agents/skills/` directory (or globally into `~/.agents/skills/` with `--global`) using [`agents-cli`](https://google.github.io/agents-cli/guide/extensions/):\n\n```bash\nagents-cli extension add GoogleCloudPlatform/cloud-foundation-fabric\n```\n\n## Available Skills\n\n- **[contributing](./contributing)**: End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\n- **[fabric-builder](./fabric-builder)**: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n- **[fabric-importer](./fabric-importer)**: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n- **[fast/networking](./fast/networking)**: Designs, customizes, and validates FAST `2-networking` YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a `2-networking` dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a `2-networking` dataset.\n- **[fast/prerequisites](./fast/prerequisites)**: Guides the user step-by-step through the prerequisites for the FAST `0-org-setup` stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for `0-org-setup` or bootstrap the FAST landing zone.\n- **[maintenance/release-process](./maintenance/release-process)**: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.\n","isInternal":false,"tokens":557,"sizeBytes":2605},{"name":"DESIGN.md","path":"tools/skill-turn-harness/docs/DESIGN.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/tools/skill-turn-harness/docs/DESIGN.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Design Decisions: Test Harness Architecture\n\n## Context\n\nThis document captures architectural decisions and considerations for the `harness.py` test harness.\n\n## LangChain Integration Analysis\n\n*Date: April 15, 2026*\n\nWe evaluated whether to integrate LangChain into the `harness.py` script. The script currently acts as a lightweight testing harness that uses `subprocess` to interact with the Gemini CLI and the native `google.genai` SDK for evaluation using structured outputs (Pydantic).\n\n### Potential Benefits of LangChain\n\n1. **Model-Agnostic Evaluators (Avoiding Self-Bias):**\n   Currently, the harness uses Gemini 2.5 Flash to evaluate the Gemini CLI. To avoid \"self-preference bias\", it is often best practice to use a different model family for evaluation. LangChain's `ChatModel` abstractions would allow swapping the evaluator model easily without rewriting API call logic.\n2. **Built-in Evaluation Frameworks:**\n   LangChain provides a dedicated evaluation module (`langchain.evaluation`). Instead of custom prompts, we could leverage pre-built evaluators (like `CriteriaEvalChain`) that are prompt-engineered to reduce hallucinations and false positives.\n3. **Observability and Tracing (LangSmith):**\n   Integration provides seamless access to LangSmith for logging evaluation runs, inspecting prompts, latency, token usage, and tracking pass/fail rates over time.\n4. **Prompt Management:**\n   LangChain's `PromptTemplate` system offers robust handling for complex evaluation criteria (e.g., few-shot examples, dynamic context).\n\n### Drawbacks and Limitations\n\n1. **Overkill for Current Scope:**\n   The current script is lightweight and readable. LangChain is a heavy dependency that introduces complex abstractions (like LCEL/Runnables), adding bloat and a steeper learning curve.\n2. **Native Structured Outputs are Sufficient:**\n   The native `google.genai` SDK already handles structured JSON outputs via `response_schema=EvaluationResult` efficiently and reliably. LangChain's structured output would merely wrap this existing capability.\n3. **External Agent Execution:**\n   LangChain excels at managing agent memory, tools, and reasoning loops. Since our harness tests an external CLI tool via `subprocess.run`, LangChain cannot orchestrate the agent and is relegated strictly to the role of a grader.\n\n### Conclusion & Recommendation\n\n**Recommendation: Hold off on LangChain for now.**\n\nThe current architecture is elegant, dependency-light, and perfectly suited for its job. The native `google.genai` SDK handles the structured Pydantic evaluation flawlessly.\n\n**When to reconsider LangChain:**\n\n- We need to evaluate the CLI using non-Google models (e.g., Claude, GPT-4) to ensure unbiased grading.\n- We require visual tracking of test runs, prompt versions, and token costs using LangSmith.\n","isInternal":false,"tokens":593,"sizeBytes":2817},{"name":"Hybrid Test Harness Architecture.md","path":"tools/skill-turn-harness/docs/Hybrid Test Harness Architecture.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/tools/skill-turn-harness/docs/Hybrid Test Harness Architecture.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# **Architecture Document: Hybrid Python/CLI Test Harness**\n\nThis document outlines the architecture for testing the Fabric FAST configuration skill. It uses a hybrid approach, executing the skill in its native CLI environment while maintaining deterministic control via a Python orchestration loop.\n\n## **1\\. The Approach: Hybrid Isolation**\n\nTo accurately test the skill in its target environment while ensuring the reliability of the test harness, the execution and evaluation layers are strictly separated:\n\n* **Orchestrator (Python):** A Python script acts as the absolute authority. It maintains the state machine, reads the playbook, injects inputs, captures outputs, and triggers evaluations.  \n* **Execution Target (Gemini CLI):** The skill is run via the gemini CLI using Python's subprocess module. This ensures the test reflects the actual user environment. State is maintained across steps using the CLI's session management flags (e.g., \\--resume).  \n* **Evaluator (Gemini API):** The semantic evaluation of the CLI's output is performed via direct API calls to Gemini 1.5 Flash. This bypasses the string-parsing unreliability of a CLI and guarantees structured JSON output via Pydantic schemas.\n\n## **2\\. The Execution Loop**\n\nThe Python orchestrator executes the following rigid sequence for each step in a defined playbook:\n\n1. **Injection:** Read the mocked user input and expected outcome from the playbook step.  \n2. **Subprocess Execution:** Invoke the Gemini CLI with the user input and the designated session\\_id. Capture stdout and trap stderr to handle hangs or crashes.  \n3. **Prompt Assembly:** Construct a strict evaluation prompt combining the exact playbook expectation with the raw string response captured from the CLI.  \n4. **Stateless Evaluation:** Call the Gemini API with the evaluation prompt, enforcing a structured output schema (Boolean Pass/Fail and Reasoning).  \n5. **Verdict Enforcement:** If the evaluator returns True, proceed to the next step. If False, immediately halt the loop, dump the interaction trace to a JSON file, and alert the developer.\n\n## **3\\. Implementation Code**\n\nThe following Python script implements the hybrid harness:\n\nimport subprocess  \nimport json  \nimport sys  \nfrom pydantic import BaseModel  \nfrom google import genai  \nfrom google.genai import types\n\n\\# 1\\. Define Strict Evaluator Schema  \nclass EvaluationResult(BaseModel):  \n    passed: bool  \n    reasoning: str\n\nevaluator\\_client \\= genai.Client()\n\ndef invoke\\_skill\\_cli(user\\_input: str, session\\_id: str) \\-\\> str:  \n    \\# Requires the CLI to support a session resume flag for state  \n    command \\= \\[\"gemini\", \"--resume\", session\\_id, \"-p\", user\\_input\\]  \n    try:  \n        result \\= subprocess.run(command, capture\\_output=True, text=True, timeout=30)  \n        if result.returncode \\!= 0:  \n            print(f\"⚠️ \\[CLI ERROR\\]: {result.stderr}\", file=sys.stderr)  \n            return f\"SYSTEM\\_ERROR: {result.stderr}\"  \n        return result.stdout.strip()  \n    except subprocess.TimeoutExpired:  \n        print(\"⚠️ \\[CLI TIMEOUT\\]\", file=sys.stderr)  \n        return \"SYSTEM\\_ERROR: Timeout\"\n\ndef run\\_hybrid\\_tuning\\_loop(playbook\\_name: str, playbook\\_steps: list, session\\_id: str):  \n    print(f\"--- Tuning: {playbook\\_name} | Session: {session\\_id} \\---\")  \n    interaction\\_log \\= \\[\\]\n\n    for step\\_index, step in enumerate(playbook\\_steps):  \n        user\\_input, expected\\_outcome \\= step\\['user\\_input'\\], step\\['expected\\_outcome'\\]  \n          \n        skill\\_response \\= invoke\\_skill\\_cli(user\\_input, session\\_id)  \n        if skill\\_response.startswith(\"SYSTEM\\_ERROR\"): break  \n              \n        eval\\_prompt \\= f\"\"\"  \n        OBJECTIVE: {expected\\_outcome}  \n        ACTUAL RESPONSE: {skill\\_response}  \n        Evaluate if the agent fulfilled the objective.  \n        \"\"\"\n\n        eval\\_response \\= evaluator\\_client.models.generate\\_content(  \n            model=\"gemini-1.5-flash\", contents=eval\\_prompt,  \n            config=types.GenerateContentConfig(  \n                response\\_mime\\_type=\"application/json\",  \n                response\\_schema=EvaluationResult, temperature=0.0  \n            )  \n        )  \n          \n        parsed\\_eval \\= json.loads(eval\\_response.text)  \n        interaction\\_log.append({\"step\": step\\_index \\+ 1, \"input\": user\\_input, \"evaluation\": parsed\\_eval})\n\n        if not parsed\\_eval\\['passed'\\]:  \n            print(f\"❌ \\[FAILURE Step {step\\_index \\+ 1}\\]: {parsed\\_eval\\['reasoning'\\]}\")  \n            with open(f\"{playbook\\_name}\\_failed.json\", \"w\") as f: json.dump(interaction\\_log, f)  \n            return False \n\n    print(f\"✅ \\[SUCCESS\\]\")  \n    return True\n\n## **4\\. Critical Implementation Warnings**\n\n* **Session Data Persistence:** The CLI likely persists session states to disk (e.g., in a local database or JSON file). If you reuse the same session\\_id for consecutive test runs without manually deleting the cache file, the skill will inherit the context of the previous run, causing immediate test failures. You must either generate a UUID for every run or build a cache-clearing mechanism into the Python script.  \n* **Context Window Discipline:** The evaluation prompt is strictly limited to the current playbook objective and the immediate CLI response. Do not feed the entire CLI conversation history to the Evaluator API, as this significantly increases the risk of hallucinated grading.","isInternal":false,"tokens":1213,"sizeBytes":5426},{"name":"README.md","path":"tools/README.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/tools/README.md","title":"tools Documentation","category":"plugin-manifest","format":"markdown","content":"# Cloud Foundation Fabric (CFF) Tools\n\nThis directory contains utility scripts and tools used to automate linting, formatting, validation, documentation generation, and testing across the Cloud Foundation Fabric repository.\n\n## Categorized Tools\n\n### 1. Documentation Generation\nTools for automatically generating documentation tables or formats from source configurations.\n\n- **[tfdoc.py](./tfdoc.py)**: Automatically generates inputs, outputs, and files documentation tables inside module READMEs.\n- **[schema_docs.py](./schema_docs.py)**: Recursively parses and compiles JSON schemas into readable, markdown-based documentation tables.\n- **[format_tftest.py](./format_tftest.py)**: Formats Terraform code blocks containing `# tftest` directives inside markdown files.\n- **[update_schema_links.py](./update_schema_links.py)**: Updates the schema URLs in YAML modelines across a directory to match a specific CDN or versioned source.\n- **[pre-commit-tfdoc.sh](./pre-commit-tfdoc.sh)**: Pre-commit hook script to run `tfdoc.py` updates and verify README alignment.\n\n### 2. Testing, Planning & Emulation\nTools to run testing sandboxes, parse plans, and simulate/evaluate agent behaviors.\n\n- **[skill-turn-harness/](./skill-turn-harness)**: A hybrid Python/SDK-based test harness for developing, running, and grading Antigravity skills.\n- **[generate_plan_summary.py](./generate_plan_summary.py)**: Generates structured plan summaries of resources and their changes for README examples or `tftest.yaml` tests.\n- **[plan_summary.py](./plan_summary.py)**: An internal helper script to parse, format, and filter Terraform plan structures for automated test assertions.\n- **[create_e2e_sandbox.sh](./create_e2e_sandbox.sh)**: Bootstraps an isolated end-to-end sandbox directory to safely provision and test CFF examples.\n\n### 3. Repository Maintenance & Automation\nTools for version management, automated reviews, GCP service definitions, and release automation.\n\n- **[changelog.py](./changelog.py)**: Automates the generation and maintenance of the `CHANGELOG.md` based on Git diffs and version changes.\n- **[versions.py](./versions.py)**: Synchronizes required engine and provider version constraints across standard provider configuration files in the repository.\n- **[build_service_agents.py](./build_service_agents.py)**: Parses the official Google Cloud documentation to build a structured representation of GCP service agents and their properties.\n- **[pr_review.py](./pr_review.py)**: Leverages the Gemini API to perform automated, context-aware code reviews on pull requests.\n- **[state_iam.py](./state_iam.py)**: Parses and displays authoritative IAM binding configurations directly from a local Terraform state file.\n\n### 4. Linting, Quality & Compliance\nTools for maintaining style guide compliance, licensing, validation, and content/rule verification.\n\n- **[check_boilerplate.py](./check_boilerplate.py)**: Scans files to verify that correct Google license headers and boilerplates are present.\n- **[check_documentation.py](./check_documentation.py)**: Recursively verifies that the variables and outputs tables generated inside module `README.md` files are up-to-date.\n- **[check_links.py](./check_links.py)**: Recursively parses Markdown files to validate external links, internal links, and anchor destinations.\n- **[check_names.py](./check_names.py)**: Evaluates name length constraints and formatting for specified GCP Terraform resources to ensure compatibility.\n- **[check_schema_docs.py](./check_schema_docs.py)**: Recursively checks if the Markdown documentation generated from JSON schemas is fully up-to-date.\n- **[check_yaml_schema.py](./check_yaml_schema.py)**: Validates YAML configuration and factory data files against their defined JSON schema modelines.\n- **[duplicate-diff.py](./duplicate-diff.py)**: Verifies content alignment for files that must remain identical across different stages or modules (e.g. schemas, policies).\n- **[lint.sh](./lint.sh)**: A shell script wrapping boilerplate, YAML, and Terraform style and format checks.\n- **[tflint-fast.py](./tflint-fast.py)**: Runs the `tflint` linter against FAST stages by setting them up in temporary isolated environments.\n","isInternal":false,"tokens":910,"sizeBytes":4205},{"name":"README.md","path":"tools/skill-turn-harness/README.md","rawUrl":"https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-foundation-fabric/HEAD/tools/skill-turn-harness/README.md","title":"skill-turn-harness Documentation","category":"plugin-manifest","format":"markdown","content":"# Hybrid Python Test Harness for Antigravity Skills\n\n## Overview\n\nThis project provides a robust, hybrid test harness for developing and evaluating Antigravity skills. It solves the \"inner dev loop\" problem by allowing you to test local, unpacked skills directly against the Antigravity engine (via the Python SDK), while using an LLM to deterministically grade the agent's behavior.\n\n## Table of Contents\n\n- [Prerequisites](#prerequisites)\n- [How to Use](#how-to-use)\n  - [Basic Usage](#basic-usage)\n  - [Command Line Options](#command-line-options)\n  - [Expected Output](#expected-output)\n- [Testing Local Skills (Inner Dev Loop)](#testing-local-skills-inner-dev-loop)\n- [Writing Playbooks](#writing-playbooks)\n- [Running the Pytest Suite](#running-the-pytest-suite)\n- [Writing Playbooks: Three Modes of Testing](#writing-playbooks-three-modes-of-testing)\n\nThe architecture relies on three main components:\n\n- **Orchestrator (Python):** Drives the execution loop, reads YAML playbooks, and manages isolated workspaces for each test run to prevent session caching issues.\n- **Execution Target (Antigravity SDK):** The skill is executed using the `google-antigravity` Python SDK, which manages the localharness engine in-process. This eliminates the dependency on a globally installed CLI.\n- **Evaluator (Gemini API):** The semantic evaluation of the agent's output is performed via direct API calls to `gemini-2.5-flash` using `google-genai`. This bypasses brittle string-parsing and guarantees structured JSON output (Pass/Fail + Reasoning).\n\n## Prerequisites\n\nYou can run the harness directly using `uv` (recommended), which will automatically handle downloading and running with the required dependencies:\n\n```bash\nuv run harness.py playbooks/my-playbook.yaml\n```\n\nAlternatively, ensure you have a Python virtual environment set up with the required dependencies:\n\n```bash\npip install google-antigravity google-genai pydantic pyyaml click pytest\n```\n\nYou also need your Gemini API key available in your environment, or stored in `~/.gemini/key.env`:\n\n```bash\nexport GEMINI_API_KEY=\"your_api_key_here\"\n```\n\n## How to Use\n\nThe main entry point is the `harness.py` CLI tool.\n\n> [!NOTE]\n> All commands in this guide assume you are running from the `tools/skill-turn-harness` directory. If running from the repository root, prefix paths accordingly (e.g., `python3 tools/skill-turn-harness/harness.py ...`).\n\n### Basic Usage\n\nTo run a test, provide a YAML playbook:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml\n```\n\n### Command Line Options\n\n- `playbook` (Required): The path to the YAML playbook defining the test steps.\n- `--log-dir <path>` (Optional): The directory where the harness will write detailed Markdown logs, session JSONs, and JSON failure dumps. Defaults to `./logs`.\n- `--skill-src <path>` (Optional): The path to a local, unpacked skill directory. See the \"Testing Local Skills\" section below for details.\n- `--env-file <path>` (Optional): The path to a standard `.env` file containing key-value pairs (e.g. `MY_SECRET=123`). This is used for secure string substitution within your playbook steps.\n- `--keep-workspace` (Optional): Preserve the temporary workspace directory (`/tmp/gemini_harness_*`) after execution to inspect files generated by the agent.\n- `--agent-model <model>` (Optional): Override the model the agent uses (e.g., `gemini-2.5-pro`). Overrides playbook definition.\n- `--evaluator-model <model>` (Optional): Override the model the test harness uses to grade and simulate (e.g., `gemini-2.5-flash`). Overrides playbook definition.\n- `--max-deviations <number>` (Optional): Set the maximum number of minor deviations/mistakes (such as rule violations or incorrect tool calls) the agent can make during autonomous/hybrid mode before the harness fails the test run. Defaults to `3`.\n- `--debug` (Optional): Enable verbose debug logging for the SDK (e.g., WebSocket traffic).\n\n⚠️ **Security Warning regarding Logs:**\nIf your playbooks require secrets (like API keys or passwords) via the `env` array, the harness will substitute them before executing the CLI. Because the harness traces all inputs and outputs for debugging, **these substituted secrets will be written in plain text** to your `logs/` directory.\nA default `.gitignore` is provided in the `logs/` directory to prevent committing these files, but care should still be taken to avoid leaking secrets into your repository.\n\n### Expected Output\n\nThe harness executes the playbook, rendering thoughts and tool calls in real-time, and streams the results to the console with active context usage stats:\n\n```text\n--- Tuning: FAST Setup PoC | Workspace: /tmp/gemini_harness_abc123 ---\n\n[Step 1]\nTester:\nHi, please activate the fast-setup-poc skill and let's configure FAST.\n  🧠 Thinking:\n  Let's activate the fast-setup-poc skill and check the requirements.\n  🛠️ [Tool Call]: list_directory(path=.)\n  ...\n✅ [PASS Step 1]: The agent greeted the user ('Hi'), confirmed it was configuring FAST, and asked for the Project ID. All parts of the objective were fulfilled.\n\n[Step 2] [Context: 4,512]\nTester:\nmy-super-project-123\n...\n\n✅ [SUCCESS] Playbook 'FAST Setup PoC' completed successfully.\n📄 Session JSON saved to: logs/FAST_Setup_PoC_session.json\n📄 Markdown log saved to: logs/FAST_Setup_PoC_log.md\n```\n\nIf a step fails, the harness halts immediately and dumps the full interaction trace to a JSON file (e.g., `logs/FAST_Setup_PoC_failed.json`) for debugging.\n\n## Testing Local Skills (Inner Dev Loop)\n\nWhen developing a complex skill (with multiple markdown files, prompt templates, or tools), you don't want to package and globally install it just to run a test.\n\nThe harness supports testing local skills directly using the `--skill-src` flag:\n\n```bash\npython3 harness.py playbooks/my-playbook.yaml --skill-src ./my-local-skill/\n```\n\n**How it works under the hood:**\n\nThe harness passes the skill path to the SDK's `LocalAgentConfig(skills_paths=[...])`. The Antigravity engine loads the skill dynamically for the duration of the session. Unlike the old CLI-based linking, this is completely isolated and does not modify your global environment.\n\n## Workspace Management & Copying Behavior\n\nBy default, the test harness executes the agent in an isolated temporary workspace (e.g., `/tmp/gemini_harness_<hash>`) to prevent session caching and protect your repository from accidental file modifications.\n\n### Copying vs. Symlinking\nTo ensure both safety and compatibility with local search tools (such as the agent's built-in `search_directory` / grep tool, which can crash when traversing directory symlinks), **the harness copies the configured playbook directories recursively instead of symlinking them**.\n\nTo keep the workspace lightweight and prevent the agent from \"cheating\" by reading the test definitions, the copy operation implements strict exclusion rules:\n- **Excluded Dependencies/Cache**: `.terraform`, `.git`, `.venv`, `venv`, `__pycache__`, `.pytest_cache` are skipped. This reduces the copied size of directories like `fast/` from 1.4GB to a few megabytes, making workspace setup near-instant.\n- **Excluded Harness**: The `skill-turn-harness` directory itself is **strictly excluded** from the copy. This prevents the agent under test from walking the workspace, reading the playbook YAML definitions, and \"cheating\" by peeking at the expected inputs/outcomes.\n\n### Linking the `skills` Directory\nIf your autonomous playbook instructs the agent to \"activate\" or \"inspect\" the skill, the model may attempt to search the workspace for the skill's source files (like `SKILL.md`). For these playbooks, ensure you add `skills` to the playbook's `link_paths` so the agent can resolve the lookup locally:\n\n```yaml\ntmpdir:\n  link_paths:\n    - fast\n    - modules\n    - tools\n    - skills # <-- Make sure to include this so the agent can find skill files\n```\n\n### Isolated Chat History\nThe harness configures the SDK to write raw session state directly to the configured `--log-dir` (under `log_dir/chats/`). This ensures that test execution conversations remain isolated and do not pollute your global Antigravity/Jetski desktop history.\n\n## Writing Playbooks\n\nPlaybooks are written in YAML. For autocompletion and validation in VS Code, add the schema annotation to the top of your playbook.\n\nIf your playbook requires environment variables (e.g., secrets), declare them in the `env` array. You can then reference them in your `steps` using `${VAR_NAME}`. If a variable is declared but not found in the environment (or passed via `--env-file`), the harness will safely halt before execution.\n\n```yaml\n# yaml-language-server: $schema=../playbooks/playbook.schema.json\nname: \"My Test Playbook\"\ntimeout: 120\nagent_model: \"gemini-2.5-pro\"\nevaluator_model: \"gemini-2.5-flash\"\nenv:\n  - MY_API_KEY\nsteps:\n  - user_input: \"Hi, activate my-skill and use this key: ${MY_API_KEY}\"\n    expected_outcome: \"The agent should greet the user and acknowledge the key.\"\n```\n\n## Running the Pytest Suite\n\nThis repository includes a `pytest` suite in the `test/` directory to test the harness itself.\n\nTo run the fast unit tests (which mock the CLI execution):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"not e2e\" -v\n```\n\nTo run the full End-to-End (E2E) test (which dynamically links the fixture skill and hits the real Gemini API):\n\n```bash\npython3 -m pytest test/test_harness.py -m \"e2e\" -v\n```\n\n## Writing Playbooks: Three Modes of Testing\n\nThe harness supports three modes of execution depending on how you structure your YAML playbook. The mode is inferred automatically based on the presence of the `steps` and/or `persona` keys.\n\n### 1. Scripted Mode (Unit / Regression)\n**Best for:** Ensuring the exact, rigid state machine of a skill hasn't broken.\n\nYou define a strict, sequential list of `steps`. The harness feeds the `user_input` and checks if the agent's response satisfies the `expected_outcome` via an LLM evaluation.\n\n```yaml\nname: \"FAST Setup PoC - Scripted\"\nsteps:\n  - user_input: \"Hi, let's configure FAST.\"\n    expected_outcome: \"The agent should greet the user and ask for the Project ID.\"\n  - user_input: \"my-super-project-123\"\n    expected_outcome: \"The agent should acknowledge the Project ID and ask for the preferred Region.\"\n```\n\n### 2. Autonomous \"Pond\" Mode (E2E / Fuzz Testing)\n**Best for:** Testing how the skill handles the messy reality of natural language and conversational drift.\n\nInstead of providing a rigid script, you define a declarative **Persona** with a \"Pond\" of knowledge and explicit `success_criteria`. A secondary LLM agent acts as the simulated user, dynamically reading the CLI's outputs, fishing data from the \"pond,\" and generating the next input until the success criteria are met or the `max_turns` limit is reached.\n\n```yaml\nname: \"FAST Setup PoC - Autonomous\"\npersona:\n  initial_user_input: \"Hi, let's configure FAST.\"\n  context: >\n    You are a GCP developer. Your Project ID is my-project-123 and region is europe-west1.\n    Do not volunteer information until the agent explicitly asks for it.\n  max_turns: 10\n  success_criteria:\n    llm_checks:\n      - \"The agent provided a final configuration summary containing the correct project_id and region.\"\n    tool_calls_contain:\n      run_shell_command:\n        - \"gcloud organizations add-iam-policy-binding\"\n    files_exist:\n      - \"0-org-setup.auto.tfvars\"\n```\n\n### 3. Hybrid Fallback Mode\n**Best for:** Testing happy-path compliance while ensuring the agent can recover from unexpected deviations.\n\nIf a playbook defines **both** `steps` and a `persona`, the harness runs in Hybrid mode. It attempts to execute the rigid `steps` first. If the skill deviates or fails a step evaluation, instead of failing the test outright, the harness **falls back** to the autonomous persona. The simulated user takes over the conversation history and attempts to guide the agent back on track to meet the `success_criteria`. If successful, the test returns a `PASS WITH WARNINGS`.\n","isInternal":false,"tokens":2787,"sizeBytes":11997}],"systemPromptSnippet":"<yakaai_skills repo=\"GoogleCloudPlatform/cloud-foundation-fabric\">\n<!-- File: AGENTS.md (Tokens: ~4 | Category: root-instruction) -->\nGEMINI.md\n\n<!-- File: skills/contributing/SKILL.md (Tokens: ~5698 | Category: anthropic-skill) -->\n---\nname: contributing\ndescription: \"End-to-end workflow for contributing to Cloud Foundation Fabric: triaging GitHub issues, proactive feature development, validating with tests and Policy Troubleshooter, and submitting sanitized Pull Requests. Use when addressing a Fabric GitHub issue, developing a module or FAST stage change, or preparing a branch for a pull request.\"\n---\n\n# Fabric Contribution Flow Skill\n\nThis skill defines the end-to-end workflow for contributing to Cloud Foundation Fabric. It supports two entry modes:\n- **Mode A: Issue Triage & Bug Fix**: You are addressing an assigned or reported GitHub Issue. Start at **Step 1**.\n- **Mode B: Proactive Development & PR Prep**: You are actively building a feature, refactoring code, or preparing an existing branch for a Pull Request. Jump directly to the **Design Sign-off Gate** before Step 2.\n\nIf it is unclear which mode applies, ask the user before proceeding.\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where your judgment is likely to diverge from a maintainer's. Keep mechanical, reversible steps (linting, tests, doc and inventory regeneration) autonomous. Gates are **blocking**: if you are running non-interactively and cannot get an answer, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Triage Disposition** | End of Step 1 (Mode A) | Whether the issue is in scope and worth pursuing: proceed, reject, or escalate. |\n| **Design Sign-off** | Before Step 2 | Approves the proposed design and scope. Mandatory for new features and interface changes; skippable for trivial fixes where the design is self-evident. |\n| **E2E Opt-in** | Step 5 | Whether to run live cloud verification. Providing a sandbox project ID **is** the consent to deploy to it. |\n| **Behavioral Verification Opt-in** | Step 5, after read-back verification | Whether to also verify runtime behavior of the deployed resources, given the described extra time and cost. |\n| **PR Approval** | Step 6 | Reviews the final sanitized PR body before submission. |\n\n---\n\n## Step-by-Step Workflow\n\n```mermaid\ngraph TD\n    M1[Mode A: Issue Triage] --> A[1. Triage Issue]\n    A --> GT{Gate: Triage Disposition}\n    GT -->|Human approves| GD{Gate: Design Sign-off}\n    GT -->|Human rejects| X[Stop / Report Back]\n    M2[Mode B: Proactive Dev] --> GD\n    GD --> B[2. Develop Fix / Feature]\n    B --> C[3. Tests, Lint & Inventories]\n    C --> D[4. Pre-Submission Self-Review]\n    D -->|Issues found| B\n    D -->|Clean| GE{Gate: E2E Opt-in}\n    GE -->|Project ID provided| G[5. Live E2E Verification]\n    GE -->|Skipped| GS{Gate: PR Approval}\n    G -->|Failures| B\n    G -->|Verified| GS\n    GS --> F[6. Commit & Submit Sanitized PR]\n```\n\n### Step 1: Triage the Issue (Mode A Only)\n\n1.  **Retrieve Issue Details**: Use the GitHub CLI to view the issue context.\n    ```bash\n    gh issue view <issue-number>\n    ```\n\n2.  **Explore the Codebase**: Identify the target module (`modules/<module-name>`) or FAST stage (`fast/stages/<stage-name>`) that requires modification.\n\n3.  **Evaluate Fit & Scope**: Assess whether the issue is relevant for Fabric. Ensure it aligns with Fabric's core philosophy (modules should be lean, composable, and represent a single resource type context). Confirm the change has a sufficiently large scope and represents a generic, valuable addition to the module or FAST stage rather than a highly specific, one-off customization.\n\n4.  **Read Provider Documentation**: If the issue involves Google Cloud resources, retrieve and read the documentation for the involved GCP resources or Terraform provider resource/datasource to ensure accurate implementation of its attributes, behaviors, and constraints.\n    *   Start from the provider registry, e.g. <https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_address>.\n    *   **Check the pinned provider range**: the repo pins the `google` provider in [default-versions.tf](../../default-versions.tf). If the feature you are implementing requires provider functionality released after the pinned minimum version, that is a good reason to bump the pin — do it across the board with `uv run tools/versions.py --provider-min-version <x.y.z> --write-defaults`, which rewrites `default-versions.tf` and propagates it to every module and stage `versions.tf` (linting enforces they stay in sync). Never edit version pins in individual modules only.\n    *   Only rely on documentation for functionality available in a **released** provider version: docs from the provider's `main` branch may describe unreleased attributes that no released provider accepts at plan time.\n    > [!WARNING]\n    > Do NOT rely solely on the proposed solution, examples, or partial specifications provided in the issue description. Always retrieve and review the complete documentation schema from the official provider registry.\n    >\n    > **Workaround for Registry Page JS/Redirection Errors**:\n    > If the registry page fails to load properly with your URL-fetching tool (e.g. returns \"Please enable Javascript\" or truncates due to HTML formatting issues), find the source markdown file on GitHub and fetch its raw content using `curl` to a scratch file (the `scratch/` directory is gitignored), using the tag of the provider release whose docs you need (e.g. `v7.40.0`):\n    > ```bash\n    > curl -s https://raw.githubusercontent.com/hashicorp/terraform-provider-google/v7.40.0/website/docs/r/workstations_workstation_config.html.markdown -o scratch/workstation_config_doc.md\n    > ```\n    > You can then search and view it locally to identify all supported arguments and blocks.\n    > Use this information to make an informed decision on which arguments to support. While 100% coverage is not always necessary or desirable, make a deliberate choice to include all arguments that are useful and align with Fabric's composability and simplicity goals, rather than missing them by omission.\n\n5.  **Gate — Triage Disposition**: Present your assessment (target module/stage, fit with Fabric's philosophy, scope evaluation) and let the human decide whether to proceed. If the issue fails the fit test, report your reasoning and stop — do NOT comment on, label, or close the issue yourself; disposition of the issue is a maintainer decision.\n\n### Gate: Design Sign-off (Modes A & B)\n\nBefore writing code, present the proposed design for approval:\n*   The target module(s)/stage(s) and the shape of the variable interface (new/changed variables, whether `context` support is added).\n*   For provider-surface changes: the explicit list of provider arguments you intend to include and exclude, with reasoning.\n*   Whether the change requires bumping the pinned provider version (a repo-wide change — see Step 1.4).\n\nThis gate is **mandatory for new features and interface changes**. For trivial fixes (typos, one-line bugfixes with an obvious solution) where the design is self-evident, state your intent and proceed without blocking.\n\n### Step 2: Develop the Fix or Feature (Modes A & B)\n\n1.  **Align with Fabric Zen**: Ensure the design focuses on composition, encapsulates logical entities (like IAM and log sinks directly inside modules), adopts common interfaces, and keeps code flat and easy to evolve.\n    > [!IMPORTANT]\n    > You MUST strictly follow the design principles and coding conventions defined in [CONTRIBUTING.md](../../CONTRIBUTING.md) and [GEMINI.md](../../GEMINI.md) when designing variables, modules, and factories.\n\n2.  **Set Up the Git Branch**:\n    *   If you are not already on a feature branch, create one named after your username and the feature/fix (e.g., `<username>/<feature-name>`, such as `johndoe/feature-name`):\n        ```bash\n        git checkout master && git pull\n        git checkout -b <username>/<feature-name>\n        ```\n    *   If preparing an existing branch (Mode B), do NOT create a new one; instead make sure it is up to date with `master` (rebase or merge) before submission.\n    *   Direct branches require push access to the repo (granted to regular contributors on request). Without it, use the fork-based flow described in [CONTRIBUTING.md](../../CONTRIBUTING.md); the rest of this workflow is identical.\n\n3.  **Apply Fabric/FAST Design Conventions**:\n    *   **Context Interpolation**: If context support is relevant and needed for the module (e.g., to support resolving symbolic references like `\"$project_ids:myprj\"`), add a `context` variable block and implement `ctx`/`ctx_p` locals in `main.tf`. Do not add it blindly if the module does not benefit from symbolic interpolation.\n    *   **Compact Variables**: Leverage objects with `optional()` attributes to keep user interfaces clean. Prefer `optional()` **without** an explicit default so the provider applies whatever default value it defines; only set a default when module logic needs a known value.\n    *   **Stable State Keys**: Always use maps instead of lists for collection variables to avoid index shifts in Terraform state.\n    *   **Scope Isolation**: Use private locals (prefixed with `_`) for intermediate transformations, reserving module-level locals for values referenced by resources.\n    *   **Locals Separation for Complex Manipulations**: When adding complex conditional strings, string interpolations, or transformations to resource attributes, compute the map in `locals` so the resource block references a clean index like `local.my_map[each.key]`. This keeps resource blocks legible (<79 chars) and avoids cluttering resource definitions.\n    *   **Validate ENUM Variables**: When adding or exposing variables that mirror ENUM values in the underlying Terraform provider, always include a `validation` block to check for allowed values at plan time. Example from `modules/gcs`:\n        ```hcl\n        variable \"storage_class\" {\n          description = \"Bucket storage class.\"\n          type        = string\n          default     = \"STANDARD\"\n          validation {\n            condition     = contains([\"STANDARD\", \"MULTI_REGIONAL\", \"REGIONAL\", \"NEARLINE\", \"COLDLINE\", \"ARCHIVE\"], var.storage_class)\n            error_message = \"Storage class must be one of STANDARD, MULTI_REGIONAL, REGIONAL, NEARLINE, COLDLINE, ARCHIVE.\"\n          }\n        }\n        ```\n\n4.  **Reference Implementations & Knowledge Sources**:\n    *   [compute-vm](../../modules/compute-vm): Ideal reference for compact variable design, optional objects with defaults, and map variables.\n    *   [project](../../modules/project): Best reference for logical entity encapsulation (IAM, log sinks, Shared VPC configurations), complex locals transformation, and context-based interpolation.\n    *   [adrs/](../../adrs/): Architectural Decision Records documenting the rationale behind key design patterns (e.g. the IAM ADR explains the full `iam*` interface). Before designing, build an index of all ADRs with their title and status:\n        ```bash\n        find adrs -name '*.md' ! -name 'README.md' | sort | while read -r f; do\n          st=$(awk '/^## Status/ {getline; while ($0 == \"\") getline; print; exit}' \"$f\")\n          printf '%s | %s | %s\\n' \"$f\" \"$(head -1 \"$f\" | sed 's/^# //')\" \"$st\"\n        done\n        ```\n        Read in full any ADR whose title relates to the area you are changing. Weigh them by status: ignore `Rejected` ones, treat `Draft`/`Proposed` as directional rather than settled, and remember that even implemented ADRs can lag behind the code — on conflict, the current code and golden-path modules are the source of truth.\n\n5.  **Maintain Code Consistency**:\n    *   Always keep variables in `variables.tf` and outputs in `outputs.tf` in strict alphabetical order. For larger modules, variables and outputs can be grouped logically into separate files (e.g. `variables-logging.tf`), each internally alphabetically sorted.\n    *   Limit line length to 79 characters (relaxed for long attribute names and descriptions).\n    *   **JSON Schema & Factory Alignment**: When a change is made to a module that implements a factory (e.g., `cloud-workstations`) or is the base for one (e.g., `net-vpc-factory` to `2-networking`), the schemas MUST be updated (including their `.schema.md` documentation files), and the factory code should be updated to mirror the changed variable surface. Ensure you regenerate the schema documentation by running:\n        ```bash\n        uv run tools/schema_docs.py\n        ```\n    *   **Duplicated Files**: Some files (e.g. schemas, factory test fixtures) are intentionally duplicated across modules and stages, and CI enforces that copies stay identical. When changing such a file, sync all its copies; the duplicate groups are listed in [tools/duplicate-diff.py](../../tools/duplicate-diff.py) — update that list when adding new duplicated files.\n\n6.  **Update or Add Tests**:\n    *   For modules: If the change introduces a new feature or configuration option, update an existing example in the module's `README.md` (or add a new one) to demonstrate its usage. Ensure the example includes `# tftest` parameters and update the corresponding inventory YAML files under `tests/modules/<module_name>/examples/`.\n    *   For FAST stages: Update `tftest.yaml` scenarios and tfvars/yaml inventories under `tests/fast/...`.\n    > [!NOTE]\n    > Module directories use hyphens (`modules/net-vpn-ha`) while test directories generally use underscores (`tests/modules/net_vpn_ha`). Always check the actual test directory name — a mistyped path makes `pytest` silently collect zero tests.\n\n### Step 3: Run Tests, Linting & Inventory Regeneration (Modes A & B)\n\nRun Python tooling through `uv run` — the scripts in `tools/` declare their dependencies inline, so no virtual environment setup is needed. For faster Terraform testing, always set `TF_PLUGIN_CACHE_DIR=/tmp/tfcache`.\n\n1.  **Run Unified Linting**: Execute `tools/lint.sh` to check copyright boilerplates, Terraform format (`terraform fmt`), alphabetical sorting, and schema validations:\n    ```bash\n    uv run --with-requirements tools/requirements.txt tools/lint.sh\n    ```\n\n2.  **Update Documentation**: If you changed variables or outputs, check consistency and regenerate the README documentation tables:\n    ```bash\n    uv run tools/check_documentation.py modules/<module-name>\n    uv run tools/tfdoc.py --replace modules/<module-name>\n    ```\n\n3.  **Run Impacted Tests**: Execute `pytest` on the target module/stage (using the plugin cache directory for speed):\n    ```bash\n    mkdir -p /tmp/tfcache\n    # tftest.yaml scenarios (note: underscores in the test directory name)\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest tests/modules/<module_name>\n    # README example tests\n    TF_PLUGIN_CACHE_DIR=/tmp/tfcache uv run --with-requirements tests/requirements.txt pytest -k 'modules and <module-name>:' tests/examples\n    ```\n\n4.  **Regenerate Test Inventories**: If module-level tests (`tftest.yaml`) or README example inventories fail due to intentional plan output changes, regenerate them using `generate_plan_summary.py`:\n    ```bash\n    # For module-level tftest.yaml inventories:\n    uv run tools/generate_plan_summary.py tests/modules/<module_name>/tftest.yaml <test-name> --save\n\n    # For README.md example inventories:\n    uv run tools/generate_plan_summary.py modules/<module-name>/README.md \"<Example Heading>\" --save\n    ```\n    > [!CAUTION]\n    > `--save` overwrites the assertion baseline, so a regenerated inventory makes the test pass **by construction**. After regenerating, diff the inventory against the previous version (`git diff tests/`), verify that every changed line maps to the intended feature or fix, and report the inventory diff to the user. If a changed line is not explained by your change, treat it as a bug in your code, not a baseline to save.\n\n### Step 4: Pre-Submission Self-Review (Modes A & B)\n\nReview your own diff (`git diff HEAD`, or `git diff master...HEAD` for an existing branch) against the repository guidelines (`GEMINI.md`, `CONTRIBUTING.md`) before submission. This is a pre-flight self-check; the canonical automated review runs in CI after the PR is opened (`Automated PR Review` workflow) — do NOT imitate its output format or header.\n\nCheck the diff against this checklist:\n\n*   [ ] Naming conventions respected (variables, outputs, resources).\n*   [ ] Context support (`ctx` variables) present where the module warrants it.\n*   [ ] IAM follows the standard `iam*` interface patterns.\n*   [ ] Examples with `# tftest` parameters cover the new/changed behavior.\n*   [ ] **CRITICAL TESTING RULE**: if resource blocks were modified (adding a new argument or modifying an existing one), the Terraform plan output changes — the corresponding test inventory YAML files (`tests/.../*.yaml`) MUST be updated in the diff. If they are not, this is a critical testing failure.\n*   [ ] JSON schemas and factory code consistent with the changed variable surface.\n*   [ ] Documentation tables regenerated (`tfdoc`), alphabetical order preserved.\n*   [ ] **Breaking Changes**: If any variable or YAML interface changed in a backwards-incompatible way, verify the PR description draft includes the `**Breaking Changes**` / `upgrade-note` block.\n\nReport the outcome as a short plain-text list of issues found (or \"no issues found\") under a `Pre-submission self-review` heading — no emojis, no status tables. Fix any issue and loop back to Step 3 until the checklist passes.\n\n### Step 5: Live Verification — E2E Sandbox & Policy Troubleshooter (Modes A & B — Optional / Recommended)\n\nWhen code modifications affect GCP resource structures or APIs, run a live E2E sandbox deployment test. Run it only once the diff is stable (tests and self-review pass) to avoid repeated cloud deployments.\n\n1.  **Gate — E2E Opt-in**: Ask the user for a GCP project ID (and if applicable, parent folder / billing account details) for E2E sandbox testing, stating explicitly that providing the project ID authorizes you to deploy and destroy resources in it with `-auto-approve`. If no project is provided, skip this step entirely.\n\n2.  **Verify Credentials**: Confirm `gcloud` authentication and Application Default Credentials are available (`gcloud auth list`) before attempting any deployment, and surface a clear error rather than failing mid-apply.\n\n3.  **Create Sandbox Directory**: Create a temporary sandbox folder under `scratch/e2e_sandbox/` (the `scratch/` directory is gitignored).\n\n4.  **Generate Test Configuration**:\n    *   Generate a root Terraform module `main.tf` in the sandbox directory.\n    *   **CRITICAL**: The `source` argument of the module call MUST point to the **local path** of the modified module in the repository (e.g. `source = \"../../modules/<module-name>\"`), NOT the GitHub reference, to ensure your local changes are tested.\n    *   Set up necessary providers and variables.\n\n5.  **Deploy**:\n    *   Run `terraform init` and `terraform apply -auto-approve` in the sandbox folder.\n    *   Confirm that all resources are created successfully. A clean apply is the baseline, not the goal: it only proves the API accepted the request, not that the change behaves as intended.\n\n6.  **Read-Back Verification (required)**:\n    *   Verify the deployed state through the service's read API (e.g. `gcloud compute backend-services describe`, or the equivalent `GET`/`describe` surface for the resource), not just the Terraform state or plan output.\n    *   For every field or block touched by the change, confirm the live resource contains the intended value and structure (e.g. one API list entry per input element, correct nesting, no silently dropped attributes).\n\n7.  **Behavioral Verification (optional — Gate)**:\n    *   **Gate — Behavioral Verification Opt-in**: after read-back verification passes, explicitly ask the user whether to also verify runtime behavior, describing what the check would entail for this specific change (resources involved, expected extra time such as propagation delays, any additional cost). Proceed only on explicit approval; skipping is a valid outcome and must be recorded in the PR body.\n    *   Verify the change behaves as expected from the perspective of a consumer of the deployed infrastructure. Examples:\n        *   Compute instances: connect to the instance (SSH/serial) and check it is reachable and its startup configuration behaves as expected.\n        *   Networking, load balancers, DNS: send traffic through the deployed path and verify routing, reachability, and response attributes (e.g. `curl` through a load balancer and inspect status and headers, resolve records, test connectivity across peerings or VPN tunnels).\n        *   Serverless and workloads (Cloud Run, Cloud Functions, GKE): invoke the deployed service and verify the response.\n        *   Data services (GCS, BigQuery, Pub/Sub): perform a representative read/write operation (upload an object, run a query, publish and pull a message).\n    *   Account for propagation delays (load balancers, CDN, IAM can take several minutes); retry before concluding failure.\n    *   **IAM conditional bindings**: use Google Cloud's official IAM Policy Troubleshooter as the behavioral check (`gcloud policy-troubleshoot iam`) to verify runtime evaluation against live target resources.\n    *   **CRITICAL**: GCP IAM runtime evaluates `resource.name` using numeric Project Numbers. Always test conditions against `--resource-name` with the numeric Project Number:\n        ```bash\n        gcloud policy-troubleshoot iam //logging.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --permission=logging.buckets.write \\\n          --principal-email=<TEST_PRINCIPAL_EMAIL> \\\n          --resource-name=projects/<PROJECT_NUMBER>/locations/global/buckets/<BUCKET> \\\n          --format=\"json(access,explainedPolicies[].bindingExplanations)\"\n        ```\n\n8.  **Destroy Resources**:\n    *   Once verified, run `terraform destroy -auto-approve` to tear down all created resources and avoid ongoing cloud costs.\n    *   Delete the sandbox directory.\n    *   If verification surfaced failures, fix the code and loop back to Step 3 before re-deploying.\n\n### Step 6: Commit & Submit the PR (Modes A & B)\n\n1.  **Sanitize Before Committing**: PII sanitization applies to **everything that leaves your machine** — commits, file contents, and the PR body. Never commit files containing real GCP project IDs, numeric project numbers, personal email addresses, or custom resource names from live testing; verify `git status` shows no stray sandbox or scratch files before staging.\n\n2.  **Commit and Push**: Stage the relevant files, commit with a short imperative message describing the change, make sure the branch is up to date with `master` (rebase if needed), and push:\n    ```bash\n    git add <files>\n    git commit -m \"Add <feature> to modules/<module-name>\"\n    git push -u origin <username>/<feature-name>\n    ```\n\n3.  **Format the PR Title**: Do NOT use Conventional Commits format (no `feat:` or `fix:` prefixes). Use a short, capitalized, imperative title (e.g., \"Add native tag bindings support to `modules/net-firewall-policy`\").\n\n4.  **Write the PR Body**:\n    *   Explain the problem, rationale, and the fix clearly.\n    *   **Document Verification & E2E Testing Methodology**: Detail local unit tests and, for E2E runs, the apply, read-back, and behavioral verification results (or note that behavioral verification was skipped) so reviewers can see the exact testing rationale and methodology.\n    *   **CRITICAL PII SANITIZATION**: Before writing the PR description, **MUST scrub all developer PII** (real GCP project IDs, numeric project numbers, personal email addresses, usernames, and custom bucket/resource names) and replace them with generic placeholders (e.g., `my-project`, `123456789012`, `user:tester@example.com`, `test-bucket`).\n    *   **Breaking Changes & Upgrade Notes**: Whenever a change requires users to upgrade their variable or YAML surface (e.g. variable type changes, deleted attributes, changed schema structures), you **MUST append** a `**Breaking Changes**` section with an `upgrade-note` code fence at the bottom of the PR description. This is automatically parsed by `tools/changelog.py` when generating release notes.\n        Format:\n        ````markdown\n        **Breaking Changes**\n\n        ```upgrade-note\n        `modules/organization`: `custom_roles` variable type changed from `map(list(string))` to `map(object({...}))`. Existing code passing permission lists directly must be updated to pass `{ permissions = [...] }`.\n        ```\n        ````\n    *   Do NOT touch `CHANGELOG.md`: release notes are generated from PR labels and `upgrade-note` blocks by maintainers using `tools/changelog.py`.\n\n5.  **Gate — PR Approval**: Present the final sanitized PR title and body to the user and get explicit approval before creating the PR.\n\n6.  **Create the PR**:\n    *   **CRITICAL PITFALL**: Do NOT pass the body inline on the CLI if it contains backticks (e.g. `gh pr create --body \"Fixes `bug`\"`) as the shell will evaluate backticks, corrupting the description. Do NOT use shell redirection or heredocs to create the body file either (see `GEMINI.md`).\n    *   Instead, write the body to a temporary file **using your file-writing tool**, then reference it:\n        ```bash\n        gh pr create --title \"Your PR Title\" --body-file /tmp/pr-body.md\n        ```\n\n<!-- File: skills/fabric-builder/references/conventions.md (Tokens: ~1188 | Category: anthropic-skill) -->\n# Cloud Foundation Fabric (CFF) Conventions for Module Consumers\n\nWhen generating Terraform code that consumes Cloud Foundation Fabric modules, you MUST adhere to the following conventions:\n\n## 1. Module Preference\n- **Prefer Modules:** Always prefer CFF modules over raw `google_*` resources.\n- **Flat Structure:** Avoid creating wrapper modules or nested module calls (modules calling other modules). Consume CFF modules directly in your root module.\n- **Module Sources:** Ask the user whether they want to use relative local paths or absolute paths referencing the GitHub repository.\n  - Relative local paths: `source = \"./modules/project\"`\n  - GitHub repository paths: `source = \"github.com/GoogleCloudPlatform/cloud-foundation-fabric//modules/project?ref={VERSION}&depth=1\"` (include version ref and `depth=1`).\n  - All modules used must specify the same version tag. By default, unless the user states otherwise, default to using the latest release tag (obtainable via `python3 scripts/fabric.py release`).\n\n## 2. Naming Conventions\n- **Use `prefix`:** For modules that support it (e.g., `project`, `gcs`), using a `prefix` variable is recommended but not mandatory.\n- **Deterministic Naming:** Prefer using structured, deterministic tokens rather than random strings.\n\n## 3. Dependency Management\n- **Output-to-Input:** Pass outputs from one module directly as inputs to another (e.g., `network = module.vpc.name`).\n- **Ordering:** CFF modules encapsulate dependencies (like API activation) in their outputs. Rely on these outputs to ensure correct creation order.\n- **Service Agents:** Use the `service_agents` output from the `project` module when granting IAM roles to Google-managed service accounts.\n- **Implicit Dependencies:** Avoid explicit `depends_on` unless absolutely necessary. Rely on implicit dependencies (passing outputs to inputs) for better readability and maintainability.\n\n## 4. Factories\n- **When to Use Factories:** Use factories when you need to manage a large number of similar resources (e.g., projects, VPCs, firewall rules) without duplicating module blocks. Factories separate configuration data (in YAML files) from Terraform logic.\n- **Main Modules with Factory Support:**\n  - `project-factory`: For bulk creation of projects, folders, and budgets.\n  - `net-vpc-factory`: For bulk creation of VPCs and associated resources.\n  - `net-vpc`: Supports loading subnets and internal ranges from folders via `subnets_folder` and `internal_ranges_folder` keys in `factories_config`.\n  - `net-vpc-firewall`: Supports loading firewall rules from a folder via `rules_folder`.\n  - `organization` and `folder`: Support loading organization policies and custom roles.\n  - `vpc-sc`: Supports loading access levels and service perimeters.\n- **Usage:** Pass the path to the directory containing YAML files to the `factories_config` variable of the respective module.\n\n## 5. Style for Root Modules\n- **File Structure:** Make file structure dependent on size. For small configurations, use a single `main.tf`. For larger configurations, split into multiple files grouped by resource type (e.g., `main.tf` for general elements, `networking.tf`, `compute.tf`, etc.).\n- **Variables & Defaults:**\n  - Define all variables in `variables.tf`, sorted alphabetically.\n  - Set defaults directly in the `default` attribute if a reasonable default exists or if provided by the user.\n  - Avoid creating a `terraform.tfvars` file unless explicitly requested by the user.\n- **Value Handling & Providers:**\n  - **No Hardcoded Values:** Never use hardcoded values for project IDs, folder IDs, or other specific identifiers unless provided by the user. If a value is required, ask the user for it or create a variable.\n  - **Provider Attributes:** Do not configure provider-level attributes like `project`, `region`, or `zone` in provider blocks (`provider \"google\"`, `provider \"google-beta\"`). All attributes must be passed to the modules explicitly in the module calls.\n- **Formatting:** Adhere to standard Terraform formatting and keep line lengths readable. Wrap complex ternaries in parentheses.\n- **No Local Exec:** Never use `local-exec` or similar provisioners to run shell commands. Rely on native Terraform resources and data sources, preferably from official providers (i.e., no third-party providers).\n\n## 5a. Version Constraints\n- **Omit Root Version Constraints:** Do not include version constraints (for Terraform or providers in `required_providers` or `required_version`) in the root module.\n- **Automatic Resolution:** Let Terraform automatically compute and resolve the required provider and Terraform versions based on the constraints defined within the consumed CFF modules.\n\n## 6. Impersonation and Backend Management\n- **Impersonation:** If the user requires service account impersonation, add the `impersonate_service_account` attribute to the `provider \"google\"` and `provider \"google-beta\"` blocks in `providers.tf`.\n- **Remote Backend:** If the user wants to use a remote backend, prefer `gcs`. Put the backend configuration in the `terraform` block inside `providers.tf`. If impersonation is used, also set it for the backend.\n\n**Template for `providers.tf`:**\n```terraform\nterraform {\n  backend \"gcs\" {\n    bucket                      = \"${bucket}\"\n    impersonate_service_account = \"${service_account}\"\n  }\n}\n\nprovider \"google\" {\n  impersonate_service_account = \"${service_account}\"\n}\n\nprovider \"google-beta\" {\n  impersonate_service_account = \"${service_account}\"\n}\n```\n\n<!-- File: skills/fabric-builder/SKILL.md (Tokens: ~597 | Category: anthropic-skill) -->\n---\nname: fabric-builder\ndescription: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.\n---\n\n# Fabric Builder\n\nThis skill generates idiomatic Terraform code using Cloud Foundation Fabric (CFF) modules, following established best practices and conventions.\n\n## Core Workflow\n\n1. **Understand Request:** Identify the GCP resources and relationships requested by the user.\n2. **Fetch Module Info:** Identify the relevant CFF module(s) from the `modules/` folder on GitHub (`GoogleCloudPlatform/cloud-foundation-fabric`).\n3. **Generate Terraform:** Output an idiomatic Terraform root module that consumes the CFF modules according to [conventions.md](references/conventions.md).\n\n## Important Guidance\n\n- **Conventions & Best Practices:** Consult [conventions.md](references/conventions.md) for guidelines on how to consume Fabric modules and write high-quality, idiomatic Terraform code (including module sources, version constraints, and provider configuration).\n- **Fetching Modules:** Do not invent module inputs or outputs. Use the `fabric.py` script to pull exact details (README, variables, examples) for a specific module from GitHub before using it.\n  - To list available modules: `python3 scripts/fabric.py modules`\n  - To fetch details for a module (README): `python3 scripts/fabric.py fetch readme <module_name>`\n  - To fetch variables files: `python3 scripts/fabric.py fetch variables <module_name>`\n  - To fetch outputs files: `python3 scripts/fabric.py fetch outputs <module_name>`\n  - To fetch schema files (useful for factories): `python3 scripts/fabric.py fetch schemas <module_name>`\n  - To fetch the latest release version: `python3 scripts/fabric.py release`\n  - **SSL Certification Verification**: If you run into SSL certificate issues on your host machine, use the `--no-ssl-verify` flag (e.g., `python3 scripts/fabric.py --no-ssl-verify fetch readme <module_name>`).\n\n## Guidelines for Output\n\n- **Root Module Output:** Your output must be a complete Terraform root module that calls the appropriate CFF modules to fulfill the user's requirements.\n- **Follow Conventions:** Strictly adhere to [conventions.md](references/conventions.md) for module sources, version constraints, provider configuration, and coding style.\n- **Use Fabric Modules:** Rely on CFF modules instead of raw `google_` resources whenever possible.\n- **Example-based Learning:** Always refer to the module's README (fetched via `scripts/fabric.py`) for correct usage examples.\n- **Formatting & Validation:** Ensure the generated code is properly formatted. If possible, run `terraform fmt`, `terraform validate`, and `terraform plan` to check your work.\n\n<!-- File: skills/fabric-importer/COVERAGE.md (Tokens: ~1745 | Category: anthropic-skill) -->\n# Import maturity matrix\n\nMaturity ladder — every resource family is at exactly one level:\n\n| Level | Meaning | Exit criterion |\n|---|---|---|\n| **N** — named | Appears in an example manifest; no mapping rules yet | — |\n| **C** — cookbook | Mapping rules + import IDs + module addresses derived from module source; not yet plan-tested live | cookbook section exists |\n| **V** — verified | Clean imports, both gates green, address table confirmed live against real Google Cloud resources | verified across live rounds |\n\nVerification target: everything in the four FAST-stage example\nmanifests at **V**. Maturity decays — every V carries the provider and\nFabric ref it was verified against; re-verify on bumps (see\n\"Regression habit\").\n\n## Matrix\n\n| Family | Module | Level | Status / Notes |\n|---|---|---|---|\n| Org IAM + audit configs | organization | **V** | Seeded audit configs, `${` escaping verified |\n| Org policies (incl. dry-run, parameters, custom constraints) | organization | **V** | Three-stream denominator enumeration verified |\n| Custom roles | organization | **V** | From-scratch and modified roles verified |\n| Org log sinks | organization | **V** | Destinations graduated to typed module references |\n| Folders + folder IAM | folder (per instance) | **V** | Parent chaining and in-place rename verified |\n| Projects, services, project IAM | project (per instance) | **V** | Project services and IAM bindings verified |\n| Service accounts + impersonation IAM | iam-service-account | **V** | Leaf-IAM opt-in verified |\n| VPC networks / subnets / routes | net-vpc | **V** | ForceNew alignment and subnet address mapping verified |\n| Log buckets (sink destinations) | logging-bucket | **V** | Reference graduation verified |\n| GCS buckets | gcs | **V** | State buckets, ForceNew project ID prefix verified |\n| BigQuery datasets | bigquery-dataset | **V** | Datasets, import IDs, `description = null` alignment verified (tables/views/routines unverified) |\n| Pub/Sub topics | pubsub | **V** | Topics, import IDs, sink reference graduation verified (subscriptions/schemas unverified) |\n| DNS zones | dns | **V** | Private zones, VPC reference link verified |\n| DNS policies / response policies | dns | **N** | Named in `examples/import-manifest.fast-networking.yaml`; no mapping rules yet |\n| NCC spokes (linked-VPC) | raw resource — **capability gap**: no Fabric module (`ncc-spoke-ra` is router-appliance only) | **V** (raw) | Upstream-issue candidate, lift with `moved {}` when a module appears |\n| Cloud Routers / NAT | net-cloudnat | **V** | Integrated router + NAT verified |\n| Firewall policies | net-firewall-policy | **C** | Mapping rules documented in cookbook (Hierarchical, Global, and Regional Network policies) |\n| VPN / Interconnect attachments | net-vpn-*, net-vlan-attachment | **C** | Mapping rules documented in cookbook |\n| KMS keyrings / keys + key IAM | kms | **V** | Seeded keyring, key, leaf-IAM on CryptoKey verified; permanent residue documented |\n| Certificate Authority Service | certificate-authority-service | **V** | CA pools: CAI type, import ID, pool address (`google_privateca_ca_pool.default[0]`), `ca_configs = {}` default-CA trap, and `publishing_options` support verified on Fabric ref containing PR #4107 (commit `a153861aae`, released in `v58.0.0`). Note: on releases lacking #4107 (`v57.0.0` and earlier), CAS reverts to capability gap |\n| VPC-SC policy / perimeters | vpc-sc | **V** | Live AccessPolicy adopted, AccessLevel, dry-run ServicePerimeter; bare-numeric policy import ID verified; `unknown`-level classification handled in `inventory.py`. Enforced perimeters (`status` block) unverified |\n| Tags (keys/values/bindings) | organization / folder / project tags | **V** | TagKey, TagValue, TagBinding, import IDs, address shapes, verified at organization, folder and project level. The `templatestring` escaping matrix (`$$${`) is DERIVED FROM MODULE SOURCE, not exercised — no seeded value contained `${` (tag IAM unverified) |\n| Billing account IAM | billing-account | **N** | — |\n| PAM entitlements | organization / folder / project `pam_entitlements` factory | **N** | Entitlements are configuration and importable. PAM **grant bindings** never enter the denominator: `inventory.py` sweeps active grants via CAI (`privilegedaccessmanager.googleapis.com/Grant`) whenever IAM is collected and strips matching bindings deterministically (target, role, requester from the grant itself), stamping them in `_meta.pam_grant_exclusions`. Structural exemption, not a waiver; declaring the Grant type in a manifest is refused. Strip logic unit-tested; not yet exercised against a live grant |\n| IAM deny policies | organization / folder / project | **N** | Not a CAI asset type; enumerated automatically by the built-in `gcloud iam policies list --kind=denypolicies` enumerator. Command shape verified, payload shape not yet exercised live |\n| Log exclusions | organization / folder / project | **N** | Neither a CAI asset type nor a gcloud command group; needs REST enumeration recorded in the run report |\n| Workload identity pools / providers | project identity-providers | **V** | WorkloadIdentityPool, WorkloadIdentityPoolProvider, import IDs, address shapes, 30-day soft-delete residue, and org policy constraints verified |\n| Anything else | — | fallback | documented raw-resource fallback + capability-gap report |\n\n## Verification Notes & Caveats\n\n- **Additive IAM default**: all V-level IAM rows above were verified\n  under **authoritative** emission (`iam` / `iam_bindings` maps), which\n  is now the opt-in (`emission.iam: authoritative`). The additive\n  default (`iam_bindings_additive` → `google_*_iam_member.bindings`)\n  is at **C**: addresses and import-ID formats derived from module\n  source and provider docs, not yet exercised live. First verified\n  additive round graduates it and re-stamps.\n- **Factory emission (opt-in)**: all matrix levels above are for the\n  default per-instance emission. The `factory` emission mode (manifest\n  `emission:` block; cookbook \"Factory emission (opt-in)\") is at **C**\n  across all carriers — address shapes and layout rules derived from\n  module source in-repo, no live import exercised yet. First verified\n  run graduates the exercised carrier and stamps it here.\n\n- **CAS & Custom Roles**: CAS `publishing_options` requires Fabric PR #4107 (`a153861aae`). Custom roles title/description convergence requires Fabric PR #4102. Both are released in `v58.0.0`.\n- **Non-CAI types**: CAI is the default source of the denominator, not its boundary. Types it does not model are enumerated with `gcloud` — automatically where `inventory.py` ships a built-in enumerator, otherwise via a manifest `enumerate:` block — or, where gcloud has no container-scoped surface, out of band against the REST API. All of it merges into the same denominator, and `inventory.py` stops the run rather than proceeding without a type it cannot enumerate. See `references/cai-blind-spots.md`.\n- **Provider Label Inheritance**: Provider v5/v6/v7 generates computed `terraform_labels: {} -> {...}` diffs for resources with inherited provider labels. These are accounted for via scoped rules in `scripts/benign-drift.yaml`.\n\n## Regression habit\n\nOn every provider minor bump, Fabric ref bump, or after long dormancy:\nre-run both gates on the persisted workspace. New provider artifacts\nfail loudly as residuals; resolve via value-guarded benign rules in\n`scripts/benign-drift.yaml`, then re-stamp `verified_against`. Never widen\na rule to make a bump pass.\n\n<!-- File: skills/fabric-importer/examples/import-manifest.fast-networking.yaml (Tokens: ~711 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-networking: the\n# networking projects and the connectivity stack.\n#\n# Maturity: core VPC surface, DNS zones, Routers/NAT, and linked-VPC NCC\n# spokes are [V]; advanced policies and VPN are [C]/[N]. Scope to your\n# networking folder. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/333333333333         # networking folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: compute.googleapis.com/Network                   # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork                # [V]\n        levels: [project]\n      - type: compute.googleapis.com/Route                     # [V] (dynamic routes waived)\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone                   # [V] modules/dns\n        levels: [project]\n      - type: dns.googleapis.com/Policy                        # [N] DNS server policies\n        levels: [project]\n      - type: dns.googleapis.com/ResponsePolicy                # [N]\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Hub           # [N] NCC hub\n        levels: [project]\n      - type: networkconnectivity.googleapis.com/Spoke         # [V] NCC linked-VPC spoke (raw fallback)\n        levels: [project]\n      - type: compute.googleapis.com/Router                    # [V] modules/net-cloudnat (router + NAT)\n        levels: [project]\n      - type: compute.googleapis.com/Address                   # [N] reserved addresses\n        levels: [project]\n      # - type: compute.googleapis.com/FirewallPolicy          # [C] net-firewall-policy\n      #   levels: [folder]\n      # - type: compute.googleapis.com/VpnGateway              # [C] net-vpn-*\n      #   levels: [project]\n      # - type: compute.googleapis.com/VpnTunnel               # [C]\n      #   levels: [project]\n      # - type: compute.googleapis.com/InterconnectAttachment  # [C] vlan attachments\n      #   levels: [project]\n\n<!-- File: skills/fabric-importer/examples/import-manifest.fast-org-setup.yaml (Tokens: ~747 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring the surface managed by FAST stage\n# 0-org-setup: org-level governance plus the IaC / billing-export /\n# observability projects, their service accounts, state buckets, tags, and WIF.\n#\n# Maturity legend (see COVERAGE.md): [V] live-verified  [C] cookbook\n# rules exist, not yet plan-tested  [N] new territory (fallback +\n# capability-gap report). Validate counts with `inventory.py collect`\n# before mapping; adjust the scope's include to your IaC folder/projects.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000   # <- your org id\n    # include/exclude match CAI `ancestors` (NUMERIC project ids).\n    # Restrict project-level types to the org-setup projects:\n    # include:\n    #   - projects/111111111111        # iac-core project NUMBER; see\n    #     import-manifest.multi-domain.yaml for selecting specific\n    #     projects per scope, and import-manifest.org-foundation.yaml\n    #     for the include-vs-exclude numeric-id trap\n    types:\n      - type: iam                                              # [V]\n        levels: [organization, project]\n      - type: org-policy                                       # [V]\n        levels: [organization]\n      - type: iam.googleapis.com/Role                          # [V] (org custom roles)\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink                   # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder       # [V]\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [organization, folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true          # impersonation grants enter the denominator\n      - type: logging.googleapis.com/LogBucket                 # [V]\n        levels: [project]\n      - type: storage.googleapis.com/Bucket                    # [V] state buckets\n        levels: [project]\n      - type: iam.googleapis.com/WorkloadIdentityPool          # [V] WIF federation\n        levels: [project]\n      - type: cloudresourcemanager.googleapis.com/TagKey       # [V] Resource Manager tags\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagValue     # [V]\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/TagBinding   # [V]\n        levels: [organization, folder, project]\n      # --- Optional additional types ---\n      # - type: cloudbilling.googleapis.com/BillingAccount     # [N] billing IAM\n      #   levels: [organization]\n\n<!-- File: skills/fabric-importer/examples/import-manifest.fast-security.yaml (Tokens: ~469 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 2-security: the security\n# projects plus KMS keyrings/keys and Certificate Authority Service (CAS).\n#\n# Maturity (see COVERAGE.md): [V] live-verified.\n# Note: KMS keyrings cannot be deleted in GCP; CAS pools bill monthly.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    # include:\n    #   - folders/222222222222         # security folder number\n    types:\n      - type: iam                                              # [V]\n        levels: [project]\n      - type: org-policy                                       # [V]\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project      # [V]\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service              # [V]\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount                # [V]\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing                  # [V] modules/kms\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey                # [V] modules/kms\n        levels: [project]\n        iam: true          # per-key IAM is leaf IAM - opt in explicitly\n      - type: privateca.googleapis.com/CaPool                  # [V] modules/certificate-authority-service\n        levels: [project]\n      # - type: privateca.googleapis.com/CertificateAuthority  # [C]\n      #   levels: [project]\n\n<!-- File: skills/fabric-importer/examples/import-manifest.fast-vpcsc.yaml (Tokens: ~303 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# ---------------------------------------------------------------------------\n# fabric-importer manifest mirroring FAST stage 1-vpcsc: the org access\n# policy, access levels, and service perimeters (modules/vpc-sc).\n#\n# Maturity: [V] live-verified (dry-run spec verified live; enforced\n# perimeter status block unverified). Access policies are org-level\n# singletons. See COVERAGE.md.\n# ---------------------------------------------------------------------------\n\nscopes:\n  - root: organizations/000000000000\n    types:\n      - type: identity.accesscontextmanager.googleapis.com/AccessPolicy      # [V]\n        levels: [organization]\n      - type: identity.accesscontextmanager.googleapis.com/AccessLevel       # [V]\n        levels: [organization, unknown]\n      - type: identity.accesscontextmanager.googleapis.com/ServicePerimeter  # [V]\n        levels: [organization, unknown]\n\n<!-- File: skills/fabric-importer/examples/import-manifest.multi-domain.yaml (Tokens: ~1330 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"multi-domain\" — PER-SCOPE TYPES\n#\n# One estate, four different depths. Each scope carries its own `types:`\n# list — there is no other place to declare types, so what a scope\n# collects is exactly what is written on it. The same type may appear in\n# several scopes with different `levels`, `iam` or `enumerate`; that is\n# the point, not an accident.\n#\n# Fail-closed rules (all exit 1 with the reason, before any API call):\n#   - a scope without a `types:` list is refused;\n#   - `types: []` is refused: an empty list is a scope that collects\n#     nothing, and that is the one thing no scope may say quietly;\n#   - a type whose `levels` cannot intersect its scope's `levels` is\n#     refused as a dead declaration (an entry listing `unknown` is\n#     exempt — see import-manifest.fast-vpcsc.yaml);\n#   - a duplicate `type:` inside one scope's list is refused.\n#\n# Note the asymmetry with `emission:` (also per scope): emission INHERITS\n# built-in defaults for families you omit, because it only decides how\n# collected assets are written. `types:` never inherits anything, because\n# it decides what is collected at all.\n#\n# After collection, read the PER-SCOPE yield tables in inventory.json\n# (`_meta.scopes[].declared_types` / `zero_yield_types`): the aggregate\n# table can be non-zero for a type while one scope's own declaration\n# yielded nothing — e.g. `iam` non-empty org-wide but empty under\n# `workloads`. Every inventory entry also names the scope(s) that\n# collected it (`scopes: [...]`).\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Org foundation: governance only, no project-contained resources.\n  - name: org-foundation\n    root: organizations/000000000000        # <- your org id\n    levels: [organization, folder]\n    emission:\n      folder: factory                       # day-2 operating model\n    types:\n      - type: iam\n        levels: [organization, folder]\n      - type: org-policy\n        levels: [organization, folder]\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  # Networking folder: the connectivity stack, plus folder-level\n  # governance for that subtree only. Same `iam` and `org-policy` types\n  # as above, different levels — per-scope lists make that expressible.\n  - name: networking\n    root: folders/111111111111              # <- networking folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: compute.googleapis.com/Network\n        levels: [project]\n      - type: compute.googleapis.com/Subnetwork\n        levels: [project]\n      - type: compute.googleapis.com/Router\n        levels: [project]\n      - type: dns.googleapis.com/ManagedZone\n        levels: [project]\n\n  # Security folder: KMS and service accounts, with leaf IAM opted in\n  # per type. `iam: true` is a property of the type ENTRY, so it is\n  # per-scope too: another scope may collect the same CryptoKey type\n  # without leaf IAM.\n  - name: security\n    root: folders/222222222222              # <- security folder\n    levels: [folder, project]\n    types:\n      - type: iam\n        levels: [folder, project]\n      - type: org-policy\n        levels: [folder, project]\n      - type: cloudresourcemanager.googleapis.com/Project\n        levels: [folder]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: cloudkms.googleapis.com/KeyRing\n        levels: [project]\n      - type: cloudkms.googleapis.com/CryptoKey\n        levels: [project]\n        iam: true\n\n  # Two workload projects, named explicitly. Project IAM is in scope\n  # here and nowhere else; no org-policy at all — a deliberate, declared\n  # gap, not a silent one.\n  - name: workloads\n    root: organizations/000000000000\n    levels: [project]\n    include:\n      - projects/333333333333               # app-prod  (project NUMBER)\n      - projects/444444444444               # app-dev\n    types:\n      - type: iam\n        levels: [project]\n      - type: serviceusage.googleapis.com/Service\n        levels: [project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n\n# ---------------------------------------------------------------------------\n# `enumerate:` blocks live in the list that declares them: a natively\n# enumerated type is swept only in the scopes whose `types:` names it.\n# Repeat the block in every scope that needs it — see\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n\n<!-- File: skills/fabric-importer/examples/import-manifest.org-foundation.yaml (Tokens: ~1912 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# ---------------------------------------------------------------------------\n# EXAMPLE import manifest: \"organization foundation\"\n#\n# This manifest reproduces exactly the surface the legacy (v1) pipeline\n# covered: org-level custom roles, org IAM (bindings + audit configs), org\n# policies, org log sinks, the folder hierarchy, and folder IAM.\n#\n# A manifest is the user-owned contract for a fabric-importer run:\n#   - inventory.py collects ONLY what the scopes declare (the completeness\n#     denominator becomes \"what you asked for\", not \"everything\");\n#   - coverage.py fails the run until every collected asset is mapped to a\n#     Terraform address or waived by a human;\n#   - re-runs are incremental: widen a scope's `types:` list (or let the\n#     org grow) and only the delta is added to the workspace. Existing\n#     addresses are never rewritten.\n#\n# The grammar is scopes-only: `scopes:` is a list, and EVERY scope carries\n# its own `types:` list. One estate, different depths per subtree = several\n# scopes, each with its own list — see import-manifest.multi-domain.yaml\n# for the worked multi-scope example and the fail-closed rules.\n# ---------------------------------------------------------------------------\n\nscopes:\n\n  # Root of the import. organizations/N, folders/N and projects/N are all\n  # valid roots; everything below is evaluated inside this subtree.\n  - root: organizations/000000000000   # <- replace with your org id\n\n    # Optional: restrict to specific subtrees inside the root. An asset is\n    # in scope only if one of these appears among its ancestors. Omit to\n    # cover the whole root.\n    # include:\n    #   - folders/111111111111\n\n    # Optional: carve out subtrees. Exclusion wins over inclusion.\n    #\n    # NOTE (live-verified): include/exclude match against the CAI\n    # `ancestors` array, which stores NUMERIC ids. Both spellings work:\n    # `projects/123456789` (the project NUMBER) matches directly, and a\n    # project ID is resolved to its number by inventory.py via\n    # `gcloud projects describe`. Prefer the number where you know it —\n    # resolution needs the describe permission, and a project that cannot\n    # be resolved is reported as an enumeration failure rather than\n    # silently matching nothing.\n    # exclude:\n    #   - projects/123456789012\n\n    # Optional: emission style, per resource family. YOUR call — the\n    # scripts do not consume it; it directs step-3 mapping (the\n    # cookbook's \"Factory emission (opt-in)\" section is normative).\n    #   per-instance (default) : one module instance per container.\n    #   factory                : data-driven YAML via the family's\n    #                            Fabric factory carrier.\n    # An org foundation is the canonical factory case: folder tree,\n    # folder IAM and org policies maintained as hierarchical data/ YAML\n    # via project-factory.\n    #\n    # `emission` is per scope and INHERITS: a family key you omit gets\n    # the built-in default (per-instance; additive for iam). `types:`\n    # does NOT: every scope declares its full list, and an omission is\n    # an error, not inheritance. Types decide the denominator; emission\n    # only decides how collected assets are written. Do not reason from\n    # one knob to the other.\n    #\n    # emission:\n    #   folder: factory\n    #   iam: additive     # default — google_*_iam_member per tuple; an\n    #                     # apply can never strip unmanaged members. Use\n    #                     # `authoritative` to make IAM fully declarative\n\n    types:\n      # ---------------------------------------------------------------\n      # IAM policies (pseudo-type). CAI models IAM as a content type\n      # attached to resources, not as an asset type, hence the reserved\n      # name `iam`. `levels` controls WHERE bindings are replicated:\n      #   organization -> org IAM bindings + audit configs\n      #                   (including logging_data_access)\n      #   folder       -> folder IAM bindings\n      # Project-level IAM stays unmanaged in this example (add `project`\n      # to take it over). Only container-attached IAM is covered by this\n      # pseudo-type; IAM on leaf assets (tag values, buckets, service\n      # accounts, ...) needs an `iam: true` opt-in on the leaf type's\n      # own entry.\n      # ---------------------------------------------------------------\n      - type: iam\n        levels: [organization, folder]\n\n      # ---------------------------------------------------------------\n      # Org policies (pseudo-type; CAI content type org-policy). One\n      # inventory entry per (resource, constraint) pair, so coverage is\n      # per-constraint. This example covers org-level only; declaring\n      # [organization] makes the folder-level restriction explicit and\n      # honest: folder policies are out of scope BY DECLARATION rather\n      # than silently dropped.\n      # ---------------------------------------------------------------\n      - type: org-policy\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Custom IAM roles. Fabric target: modules/organization factory,\n      # data/custom-roles/<role_id>.yaml. Org-level only; project-level\n      # custom roles would need `project` here.\n      # ---------------------------------------------------------------\n      - type: iam.googleapis.com/Role\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Log sinks. Fabric target: modules/organization logging_sinks\n      # locals (with iam=false; _Default/_Required are quarantined).\n      # Folder and project sinks stay unmanaged here.\n      # ---------------------------------------------------------------\n      - type: logging.googleapis.com/LogSink\n        levels: [organization]\n\n      # ---------------------------------------------------------------\n      # Folder hierarchy. Fabric target: one modules/folder instance per\n      # folder (stable instance keys; parent chaining by module\n      # reference). `levels` refers to the CONTAINER of the asset: a\n      # folder whose parent is the org sits at level `organization`, a\n      # nested folder at `folder`.\n      # ---------------------------------------------------------------\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n# ---------------------------------------------------------------------------\n# What this manifest deliberately does NOT cover (would have been silent\n# gaps in v1; here they are visible declarations — add entries to the\n# scope's `types:` list to take them over):\n#\n#   - type: cloudresourcemanager.googleapis.com/Project\n#     levels: [organization, folder]\n#   - type: orgpolicy.googleapis.com/CustomConstraint\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagKey\n#     levels: [organization]\n#   - type: cloudresourcemanager.googleapis.com/TagValue\n#     levels: [organization]\n#   - type: essentialcontacts.googleapis.com/Contact\n#     levels: [organization]\n#   - type: compute.googleapis.com/Network        # modules/net-vpc\n#   - type: iam.googleapis.com/ServiceAccount     # modules/iam-service-account\n#     levels: [project]\n#     iam: true    # ALSO collect IAM attached to each SA (impersonation\n#                  # grants). Leaf-asset IAM never enters the denominator\n#                  # without this per-type opt-in.\n# ---------------------------------------------------------------------------\n\n# ---------------------------------------------------------------------------\n# TYPES CLOUD ASSET INVENTORY DOES NOT MODEL\n#\n# A type CAI cannot see would be invisible to BOTH gates at once, so it\n# is enumerated by other means and merged into the same inventory —\n# `gcloud` first. Declare it with an `enumerate:` block in the `types:`\n# list of every scope that needs it (per-scope lists never inherit):\n#\n#   - type: iam.googleapis.com/DenyPolicy      # not in the CAI catalogue\n#     levels: [organization, folder]\n#     enumerate:\n#       command: [iam, policies, list, --kind=denypolicies]\n#       container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n#       key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n#\n# Guard rails (read-only verbs only, no --filter/--limit, unique key\n# templates), the built-in enumerator table, and the full ladder (CAI ->\n# gcloud -> REST API -> signed waiver) are in\n# references/cai-blind-spots.md.\n# ---------------------------------------------------------------------------\n\n<!-- File: skills/fabric-importer/examples/waivers.example.yaml (Tokens: ~386 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED waiver ledger (example).\n#\n# A waiver excludes one in-scope inventory key from the coverage gate:\n# \"we saw it, we decided not to manage it, here is why, signed\". The model\n# operating the skill MUST NOT add or edit waivers; it may only propose\n# them in its report for a human to review and commit.\n#\n# Every waiver requires key + reason. `signed_by` records who accepted\n# it — coverage.py reports waivers without it, and hard-fails them under\n# --require-signed-waivers (recommended whenever a human is in the\n# loop). Include the date in the signed_by value for auditing.\n\nwaivers:\n  - key: //cloudresourcemanager.googleapis.com/folders/000000000001\n    reason: >-\n      Decommissioning next quarter; not worth importing. Tracked in\n      TICKET-123.\n    signed_by: alice@example.com 2026-08-21\n\n  - key: //logging.googleapis.com/organizations/000000000000/sinks/tmp-debug\n    reason: temporary debugging sink owned by the SRE oncall; do not manage\n    signed_by: bob@example.com 2026-08-21\n\n<!-- File: skills/fabric-importer/README.md (Tokens: ~6910 | Category: anthropic-skill) -->\n# fabric-importer\n\n`fabric-importer` reverse-engineers an existing Google Cloud organization into Cloud Foundation Fabric Terraform modules with native `import` blocks. An AI agent generates the module mapping; a set of frozen verification scripts proves the result is complete, attribute-accurate, and plan-converged. It never modifies anything in your live cloud environment.\n\nStart with the document for your role:\n\n- **Human Operator / Requesting an Import**: Follow this guide below.\n- **AI Agent Performing the Work**: Follow [SKILL.md](./SKILL.md).\n- **Supported Resources & Maturity**: See the [Maturity Matrix](./COVERAGE.md).\n\n---\n\n## The Design in One Paragraph\n\nMost infrastructure importers rely on brittle per-resource generator pipelines that quickly grow stale. This skill inverts that model: the AI agent inspects the live inventory and Fabric module sources directly, then emits idiomatic Fabric module calls paired with native Terraform 1.5+ `import {}` blocks. Two frozen verification gates validate the output. This is sound because every mistake is immediately visible: a missing attribute or schema mismatch causes `terraform plan` to propose unexpected changes, and a missed resource is flagged during inventory reconciliation. Verification per run replaces trust in generator code.\n\n---\n\n## What you provide, and what the tools work out\n\nYou write one file: `import-manifest.yaml`. Everything else is derived from it.\n\nThe important part is what that file contains. **It names resource types, not\nresources.** You never write a list of your 47 org policies. You write:\n\n```yaml\nscopes:\n  - root: organizations/123456789012\n    types:\n      - type: org-policy\n        levels: [organization]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n```\n\nThat means \"in this organization, take over org policies attached at org level,\nand every folder\". Finding the actual 47 policies and 12 folders is the tool's\njob — and it has to be. If a human wrote the list of resources, \"everything is\ncovered\" would only ever mean \"everything I remembered to write down\". The\nmanifest is a deliberately coarse statement of intent; turning intent into an\nexact list is where people miss things, so a frozen script does it and refuses\nto hand back a partial answer.\n\nThe run answers five questions in order, and only the third one is yours:\n\n| Question | Answered by | Binding? |\n|---|---|---|\n| What do I already manage in Terraform? | `manifest_from_state.py` | no, advisory |\n| What actually exists in the cloud? | `inventory.py survey` + `manifest_init.py` | no, advisory |\n| **What do I want under management?** | **you, in `import-manifest.yaml`** | **yes** |\n| Which live resources does that mean, exactly? | `inventory.py collect` → `inventory.json` | yes |\n| How much of it is done? | `coverage.py` | yes |\n\nThe first two exist to break a chicken-and-egg problem: you cannot write a\nsensible manifest about an estate you cannot see, and the tools that measure the\nestate need a manifest first. Neither is a gate, neither produces anything\nbinding, and you can skip both if you already know your scope. Run them anyway\nthe first time: the gap between \"what Terraform manages\" and \"what exists\" is\nusually the most useful thing discovered all week.\n\n### Different depth per subtree: per-scope `types:`\n\nOne estate rarely wants the same depth everywhere — governance at org and\nfolder level, the full connectivity stack in the networking folder, two named\nworkload projects and nothing else. `scopes:` is a list, and **every scope\ncarries its own `types:` list**; there is no other place to declare types, so\nwhat a scope collects is exactly what is written on it:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: org-policy\n        levels: [organization, folder]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: workloads\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111\n    types:\n      - type: iam\n        levels: [project]\n      - type: storage.googleapis.com/Bucket\n        levels: [project]\n```\n\nThe same type may appear in several scopes with different `levels`, `iam` or\n`enumerate`. Because a type list decides the denominator, the rules fail the\nrun with a message rather than warning — before any API call, exit 1:\n\n| Rule | Why |\n|---|---|\n| Every scope declares `types:`; `types: []` is rejected | A scope that collects nothing passes every check by not collecting |\n| A type whose `levels` cannot intersect its scope's `levels` is rejected | A dead declaration reads like coverage and produces none (an entry listing `unknown` is exempt) |\n| A type declared twice in one list is rejected | Last-wins would quietly narrow levels |\n| The retired grammar (top-level `scope:` / `types:`) is rejected with migration instructions | Half-accepting it would change what the denominator means — see [references/manifest-migration.md](./references/manifest-migration.md) |\n\n`examples/import-manifest.multi-domain.yaml` is a worked four-scope manifest;\n`examples/import-manifest.org-foundation.yaml` is the single-scope reference.\n\nAfterwards, read the **per-scope** tables in `inventory.json`:\n`_meta.scopes[].declared_types` and `_meta.scopes[].zero_yield_types`.\n`_meta.declared_types` is the aggregate across scopes, and the aggregate can\nhide a scope whose own declaration yielded nothing — a type non-zero org-wide\nand zero in the one scope you cared about. Every inventory entry (and\ntherefore every worklist entry) also names the scope(s) that collected it in\nits `scopes` field.\n\n### `survey` and `collect` are not the same thing\n\nThey are the same script and both list resources, which makes them easy to\nconfuse. Their purposes are opposite.\n\n`survey` runs **without** a manifest. It browses, so that a human can decide. Its\noutput is a conversation aid and is never used to judge anything.\n\n`collect` runs **only** with an approved manifest. Its output is the\n**denominator**: the exact list of live resources matching what you agreed.\nEverything downstream is measured against it.\n\n### The two gates answer different questions\n\nThis is the part worth reading twice, because neither gate can catch the other's\nfailure.\n\n| Gate | Compares | Answers |\n|---|---|---|\n| 1 — `coverage.py` | `inventory.json` against the **text of your code** | did you cover everything? |\n| 2 — `verify_plan.py` | the **Terraform plan** against reality | is what you wrote correct? |\n\nGate 1 never runs Terraform and never contacts your cloud. It reads\n`inventory.json`, the `import {}` blocks it parses out of your `*.tf` files, the\n`coverage-map.yaml` ledger, and `waivers.yaml`. Every asset in the denominator\nmust be in the ledger or in the waivers.\n\nGate 1 can pass on completely wrong code — right addresses, garbage\nconfiguration. The plan catches that, as diffs.\n\nGate 2 can pass on a workspace covering 3 of your 59 resources. A plan of three\nclean imports and nothing else is perfectly converged; Terraform has no idea the\nother 56 exist. **A converged plan says nothing about completeness**, because\nTerraform only knows about what you wrote down. Completeness can only be judged\nagainst a list built independently of the code, which is exactly what\n`inventory.json` is.\n\n### `inventory.json` is the only door\n\nThe loop is closed in both directions:\n\n- a ledger key that is not in the inventory fails gate 1 as `stale`\n- an `import {}` block that no ledger key claims fails gate 1 as an `orphan`\n\nSo nothing can be imported that the denominator does not contain, and nothing in\nthe denominator can be quietly dropped. That is what makes \"both gates green\"\nmean something precise: everything the manifest declared is either in code or\nwaived by name, and applying that code would change nothing.\n\n---\n\n## Human-in-the-Loop Gates\n\nGate on steps that are hard to reverse, costly, or where human judgment is required (e.g. narrowing scope, waiving assets, or accepting provider drift). Keep mechanical, reversible steps (asset collection, scaffolding, linting, plan checks) autonomous. Gates are **blocking**: if running non-interactively and confirmation cannot be obtained, stop — never assume approval.\n\n| Gate | When | What the human decides |\n| :--- | :--- | :--- |\n| **Scope Approval** | Manifest drafting, before any enumeration | Reviews and commits `import-manifest.yaml`: which resource types each scope declares, at which hierarchy levels (org/folder/project), under which included/excluded subtrees. |\n| **Waiver Signing** | Completeness Gate (`coverage.py`) | Reviews deliberate exclusions in `waivers.yaml` and signs them with attribution (`signed_by`) for unmanaged or auto-generated resources (e.g. default log sinks, default compute service accounts). |\n| **Benign Drift Review** | Plan Convergence Gate (`verify_plan.py`) | Evaluates proposed cosmetic provider quirks (e.g. computed labels, default timeouts) and commits reviewed entries to `scripts/benign-drift.yaml`. |\n| **Final Review & Apply** | Handover, before `terraform apply` | Inspects the final zero-drift plan, run report, and input provenance digests before running `terraform apply` on their own schedule. |\n\n---\n\n## How the work flows\n\nThree views. The main loop, then the two places where a single item funnels\nthrough a decision — enumeration and mapping.\n\n### The main loop\n\n```mermaid\nflowchart TD\n    A[\"Existing .tfstate files<br/>or the live estate\"] --> B[\"Draft import-manifest.yaml<br/>manifest_from_state.py, or<br/>inventory.py survey + manifest_init.py\"]\n    B --> G0{{\"HUMAN GATE 1<br/>approve the scope\"}}\n    G0 --> C[\"inventory.py collect\"]\n    C --> D[(\"inventory.json<br/>THE DENOMINATOR\")]\n    D --> E[\"coverage.py --worklist-out\"]\n    E --> F[\"AGENT WORK<br/>module config + import blocks<br/>+ coverage-map.yaml\"]\n    F --> G1{\"GATE 1 machine<br/>coverage.py\"}\n    G1 -- \"missing assets\" --> W{{\"HUMAN GATE 2<br/>sign a waiver\"}}\n    W --> G1\n    G1 -- \"gaps to close\" --> F\n    G1 -- \"reconciled, exit 0\" --> P[\"terraform plan\"]\n    P --> G2{\"GATE 2 machine<br/>verify_plan.py\"}\n    G2 -- \"residual diff\" --> F\n    G2 -- \"provider quirk?\" --> BD{{\"HUMAN GATE 3<br/>accept benign drift\"}}\n    BD --> G2\n    G2 -- \"converged, exit 0\" --> R[\"Run report\"]\n    R --> NEXT[\"Widen the manifest<br/>for the next round\"]\n    NEXT --> G0\n    R --> G3{{\"HUMAN GATE 4<br/>apply sign-off\"}}\n    G3 --> DONE([\"terraform apply<br/>run by the operator\"])\n```\n\nEverything above the scope gate is advisory. Everything below it is mechanical\nand binding.\n\n### Funnel 1: how a declared type becomes inventory entries\n\n```mermaid\nflowchart TD\n    T[\"One type declared in a<br/>scope's types: list\"] --> Q1{\"pseudo-type?\"}\n    Q1 -- \"iam\" --> S1[\"asset list<br/>--content-type=iam-policy\"]\n    Q1 -- \"org-policy\" --> S2[\"three streams merged:<br/>org-policy content type<br/>+ orgpolicy Policy assets<br/>+ gcloud org-policies list<br/>per container\"]\n    Q1 -- \"no, a real asset type\" --> Q2{\"enumerator registered?<br/>built-in table, or a<br/>manifest enumerate: block\"}\n    Q2 -- \"yes\" --> S3[\"gcloud list per container<br/>CAI never consulted\"]\n    Q2 -- \"no\" --> S4[\"gcloud asset list<br/>batched with the other types\"]\n    S4 --> Q3{\"outcome\"}\n    Q3 -- \"ok\" --> OUT\n    Q3 -- \"type not in the<br/>CAI catalogue\" --> STOP[\"STOP, exit 3<br/>fix the type string,<br/>add an enumerator,<br/>or drop it deliberately\"]\n    Q3 -- \"any other error<br/>403, ACM types\" --> S5[\"asset search-all-resources\"]\n    S5 -- \"ok\" --> OUT\n    S5 -- \"still failing\" --> STOP2[\"STOP, exit 3<br/>partial denominator refused\"]\n    S1 --> OUT[(\"inventory.json\")]\n    S2 --> OUT\n    S3 --> OUT\n```\n\nNo path exits with \"skip it\". Every branch ends in the denominator or in a stop,\nbecause an unenumerated asset is invisible to both gates at once — it would pass\nevery check by not existing. The funnel runs once per scope: the same type can\nenter it from two scopes' lists with different levels, and a type declared in\nonly one scope is swept only there.\n\n### Funnel 2: how a worklist item becomes code\n\n```mermaid\nflowchart TD\n    I[\"Worklist item\"] --> M{\"Does the canonical<br/>Fabric module express<br/>the live resource?\"}\n    M -- \"yes\" --> A[\"Module config<br/>+ import block<br/>+ coverage-map entry\"]\n    M -- \"module exists but cannot<br/>express an attribute\" --> B[\"Raw google_* resource<br/>+ MODULE CAPABILITY GAP<br/>in the report\"]\n    M -- \"no module covers<br/>the type at all\" --> C[\"Raw google_* resource<br/>+ report, propose upstream\"]\n    M -- \"deliberately out of scope\" --> D{{\"HUMAN GATE<br/>signed waiver\"}}\n    M -- \"a residual you<br/>cannot explain\" --> STOP[\"STOP<br/>report it, never rationalise it\"]\n    A --> P[\"Both gates\"]\n    B --> P\n    C --> P\n    D --> X[\"Not in the workspace,<br/>named in the report\"]\n```\n\nThree of those four outcomes are successes. That matters: the temptation at the\nmiddle two is to force the module to fit, or to call the leftover diff benign.\n\n---\n## Step-by-Step Operator Guide\n\n### 1. Declare the Scope (`import-manifest.yaml`)\nYou and the agent agree on an `import-manifest.yaml` declaring, per scope, which resource types to import (e.g., folders, service accounts, VPC networks, org policies), at which container levels (organization, folder, or project), and which subtrees to include or exclude — see [Different depth per subtree](#different-depth-per-subtree-per-scope-types).\n\nThis is the only file you author, and it names types, not resources. The two\noptions below are drafting aids that answer different questions — A tells you\nwhat Terraform already manages, B tells you what exists. Run both the first\ntime and compare them; run neither if you already know your scope. Neither\noutput is binding until you have reviewed it and committed it.\n\n**Option A — Inferred from existing Terraform state(s):**\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n```\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate:**\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json --scope organizations/ORG_ID --out import-manifest.yaml\n```\nReady-made examples aligned with FAST stages are provided in\n[`examples/`](./examples/); `import-manifest.org-foundation.yaml` is the\nsingle-scope reference and `import-manifest.multi-domain.yaml` the per-scope\nmulti-domain one.\n\n### 2. Enumerate the Denominator (`inventory.json`)\nThe enumeration script queries Cloud Asset Inventory (CAI) and service APIs to construct the exact denominator of all matching live assets:\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nCAI is the default source of the denominator, not its boundary. CAI does\nnot model every GCP resource, and a type it cannot see would otherwise be\ninvisible to both gates at once — so the tool routes around it rather\nthan shrinking the denominator: built-in `gcloud` enumerators for types\nknown to be absent from the catalogue (declaring the type is all it\ntakes), automatic sibling sweeps for the Compute families whose primary\nCAI surface splits them into separate scoped types (accounted under the\ndeclared type, real type preserved in `_meta.split_type_sweeps`), and a\nhard stop with the remedy where no enumerator exists — either the type\nstring is wrong (checked against the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types)),\nor the manifest needs a native `enumerate:` block: a read-only `gcloud`\ncommand run per in-scope container, normalized into the same inventory,\ndeclared in the `types:` list of each scope that needs it (per-scope\nlists never inherit). The full ladder (CAI → gcloud → REST API → signed\nwaiver), the enumerator syntax and guard rails, and the split-taxonomy\nmechanics are in\n[references/cai-blind-spots.md](./references/cai-blind-spots.md).\n\nThe split-type table is a frozen snapshot of a document Google changes,\nso check it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nIt costs one extra `search-all-resources` call per scope and fails the\nrun if the search surface returns an asset the list sweep did not.\n\nThe run stays auditable without becoming unreadable: every run closes\nwith a one-line cost summary on stderr (`7 gcloud call(s) in 12.0s:\nasset list x5, org-policies list x2`), `--verbose` (after the\nsubcommand) prints each command as it runs with outcome, duration and\nitem count, the full log is written to `_meta.api_calls`, and every\nnon-CAI sweep is recorded verbatim in `_meta.native_sweeps` so a\nreviewer can re-run it.\n\nProvenance is per scope: each `_meta.scopes[]` entry records its own\n`declared_types` yield counts and `zero_yield_types`, and every\ninventory entry names the scope(s) that collected it.\n`_meta.declared_types` is the aggregate — read the per-scope counts\ntoo, because a type that yields zero in one scope and non-zero in\nanother does not show up in the aggregate zero-yield warning (the\nper-scope warning on stderr does).\n\n### 3. Compute Delta Worklist\nThe completeness tool reconciles the denominator against any existing code and written waivers. This is the same program that runs as gate 1 in step 5 — the same comparison, run for a different reason. Here it tells you what is left to do; there it decides whether anything is. On the first round everything is outstanding, so the worklist is the whole denominator. On later rounds it is only the delta, which is what makes repeated rounds cheap:\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\n### 4. Agent Maps & Emits Terraform\nFor each item in `worklist.yaml`, the agent writes:\n1. Canonical Fabric module configuration matching the live state.\n2. A paired Terraform `import {}` block:\n   ```hcl\n   import {\n     to = module.folder-team-a.google_folder.folder[0]\n     id = \"folders/123456789012\"\n   }\n   ```\n3. A ledger entry in `coverage-map.yaml` linking the asset to its Terraform address.\n\n### 5. Automated Verification (Definition of Done)\nWork is complete only when two independent gates pass with exit code `0`. They are independent in a specific sense: each catches a failure the other cannot see. Gate 1 can pass on wrong code, and gate 2 can pass on a workspace covering three resources out of fifty-nine. See [The two gates answer different questions](#the-two-gates-answer-different-questions).\n- **Completeness Gate (`coverage.py`)**: Reconciles the inventory against `coverage-map.yaml`, `waivers.yaml`, and emitted `import {}` blocks. Every asset must be mapped or waived. It reads files only — no Terraform, no cloud access — so it needs neither credentials nor a `terraform init` to run.\n- **Plan Convergence Gate (`verify_plan.py`)**: Consumes `terraform show -json <planfile>` output (file argument or stdin) and classifies the planned actions. It never invokes Terraform itself. The plan may only contain clean imports, no-ops, and human-reviewed benign drift entries from `scripts/benign-drift.yaml`. Any residual attribute modification or destruction fails the gate.\n\nRun both gates like this:\n\n```bash\nterraform -chdir=tf fmt -recursive\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # a stale plan must never answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` to the plan: it returns 2 for any\nnon-empty plan, and a converged import plan is always non-empty.\n`verify_plan.py` is the verdict.\n\n### 6. Review and Apply\nThe resulting workspace (`tf/`) is clean, formatted, and ready for you to review and run `terraform apply` at your own pace. The skill **never** runs `terraform apply` or mutates live cloud resources.\n\n---\n\n## Script reference\n\nRun every script from the skill directory. All output paths are relative\nto the current working directory.\n\n| Script | Purpose | Flags |\n|---|---|---|\n| `inventory.py survey` | Enumerate everything in scope, to draft a manifest from | `--scope` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants` |\n| `manifest_init.py` | Draft a manifest from a survey (Mode B) | `--survey` (required), `--scope` (required), `--out` |\n| `manifest_from_state.py` | Infer a manifest from existing `.tfstate` (Mode A) | `--state` (1+, required), `--out` (`-` for stdout), `--force` to overwrite an existing manifest |\n| `inventory.py collect` | Build the denominator from CAI | `--manifest` (required), `--out`, `--verbose`, `--include-deleted`, `--include-logging-defaults`, `--include-pam-grants`, `--verify-search-parity` |\n| `coverage.py` | Gate 1 — completeness | `--inventory` (required), `--workspace` (required), `--coverage-map`, `--waivers`, `--require-signed-waivers`, `--allow-empty-inventory`, `--worklist-out` |\n| `verify_plan.py` | Gate 2 — plan convergence | positional plan JSON (default stdin), `--rules`, `--allow-empty-plan` |\n| `integrity.py` | Print the frozen-tools provenance digest | `--verbose` for per-file digests |\n\n`inventory.py` requires the `survey` or `collect` subcommand; it has no\ntop-level flags. `--verbose` therefore goes after the subcommand\n(`inventory.py collect --manifest m.yaml --verbose`), not before it.\n\nExit codes — `coverage.py`: `0` reconciled, `1` malformed input, `2` gaps\nor problems. `verify_plan.py`: `0` converged, `1` malformed input, `2`\nresidual changes, `3` converged but ADVISORY (a substituted `--rules`\nfile). A substituted ruleset can never exit `0`; a residual plan still\nexits `2`. `inventory.py`: `0` ok, `1` a manifest the validator refuses\n(retired grammar, missing/empty per-scope `types:`, duplicate type,\ninvalid level, dead per-scope declaration — printed with the reason\nbefore any API call), `3` a partial denominator (unsupported type,\ntolerated sweep failure, split-parity gap). Note that argparse usage\nerrors also exit `2`, so read the message, not only the code.\n\nThere is no checked-in expected digest. To check a recorded gate run,\ncompute `uv run scripts/integrity.py` from a pristine checkout of the\nsame commit and compare it with the `frozen tools:` line in the captured\noutput.\n\n---\n\n## Prerequisites & Permissions\n\n### System Requirements\n- **Terraform**: `v1.5.0+` (required for native `import {}` blocks).\n- **Google Cloud SDK (`gcloud`)**: Authenticated with a configured quota project.\n- **[`uv`](https://docs.astral.sh/uv/)**: the recommended way to run the scripts.\n  Every script declares its own dependencies inline\n  ([PEP 723](https://peps.python.org/pep-0723/), the same convention as\n  `tools/tfdoc.py`), so `uv run scripts/<name>.py` resolves them per script with\n  nothing to install and nothing to activate.\n- **Python**: `3.10+` if you would rather not use `uv`. Then run the scripts with\n  `python3` and install `PyYAML` yourself; the commands are otherwise identical.\n\n### Minimal Read-Only IAM Permissions\nRun the import using a dedicated read-only identity. The following roles on the organization scope provide full read visibility:\n\n| Role | Purpose |\n|---|---|\n| `roles/cloudasset.viewer` | CAI asset inventory enumeration across the hierarchy |\n| `roles/resourcemanager.organizationViewer` | Organization resource details |\n| `roles/resourcemanager.folderViewer` | Folder hierarchy discovery |\n| `roles/viewer` | Read-only inspection of imported resources via service APIs |\n| `roles/iam.securityReviewer` | IAM policy discovery at organization, folder, and project levels |\n| `roles/orgpolicy.policyViewer` | Organization policy evaluation |\n| `roles/accesscontextmanager.policyReader` | VPC Service Controls discovery (when VPC-SC is in scope) |\n\nNo write or admin permissions are required.\n\n---\n\n## User Ownership Boundary\n\nThree human-owned files govern the process. The agent may propose edits, but only you commit them:\n\n| File | Purpose |\n|---|---|\n| `import-manifest.yaml` | Declares the scopes, each with its own resource-type list, container levels, and subtree filters. |\n| `waivers.yaml` | Written waivers for resources deliberately excluded from management (e.g. `_Default` log sinks, default compute service accounts). Requires a `signed_by` attribute. |\n| `scripts/benign-drift.yaml` | Scoped provider quirks accepted as cosmetic diffs (e.g., computed provider labels or default timeouts). |\n| `references/address-map.yaml` | The fielded subset of the mapping cookbook: asset type, module, address pattern, import ID, verification round. Advisory, not a gate input — see below. |\n\n---\n\n## Repository Layout\n\n```text\nskills/fabric-importer/\n├── SKILL.md                     # Agent protocol and operational contract\n├── README.md                    # Human operator guide and architecture overview\n├── COVERAGE.md                  # Resource family maturity matrix (N / C / V)\n├── scripts/                     # Verification gates and helper utilities\n│   ├── inventory.py             #   Asset enumeration (CAI + API sweeps)\n│   ├── coverage.py              #   Completeness gate & delta worklist generator\n│   ├── verify_plan.py           #   Plan-convergence gate (benign-drift aware)\n│   ├── manifest_from_state.py   #   Terraform state-driven manifest inference\n│   ├── manifest_init.py         #   Starter manifest drafting assistant\n│   ├── integrity.py             #   Input binding & runtime provenance stamping\n│   ├── address_map.py           #   Address-map validator, cookbook linter, table renderer\n│   └── benign-drift.yaml        #   Human-reviewed provider quirk ruleset\n├── examples/                    # Manifest and waiver templates\n│   ├── import-manifest.org-foundation.yaml\n│   ├── import-manifest.multi-domain.yaml\n│   ├── import-manifest.fast-org-setup.yaml\n│   ├── import-manifest.fast-security.yaml\n│   ├── import-manifest.fast-vpcsc.yaml\n│   ├── import-manifest.fast-networking.yaml\n│   └── waivers.example.yaml\n├── references/                  # Normative mapping rules & technical gotchas\n│   ├── mapping-cookbook.md      #   Traps, reasoning, capability gaps (prose)\n│   ├── address-map.yaml         #   Addresses, import IDs, asset types (data)\n│   ├── inferring-manifests-from-state.md # State-driven manifest inference workflow\n│   ├── manifest-migration.md    #   Migrating manifests from the retired grammar\n│   ├── cai-blind-spots.md       #   Known CAI gaps and service API mitigations\n│   └── operating-contract.md    #   Safety invariants and trust boundaries\n└── tests/                       # Unit test suite\n```\n\n### Where mapping knowledge lives\n\nThe cookbook carries two different kinds of knowledge, and they are now\nstored differently because they fail differently.\n\n**Fielded tuples** — asset type, module, address pattern, import ID,\nverification round — live in `references/address-map.yaml`. They are\nuniform and mechanically checkable, and they were the part that kept\nbeing duplicated: the same address appeared in per-type prose and again\nin a hand-maintained markdown table, and three families were once\ndocumented twice because nothing could detect it. Two invariants are now\nenforced:\n\n- an address pattern is claimed by exactly one entry;\n- a CAI asset type serviced by more than one module (the live case:\n  `compute.googleapis.com/Router`, created by both `net-cloudnat` and\n  `net-vpn-ha`) must say how to tell the carriers apart.\n\n**Reasoning** — ForceNew traps, provider quirks, waiver postures,\ncapability gaps — stays in `references/mapping-cookbook.md`, in prose,\non purpose. It is causal and only degrades when squeezed into a schema.\n\n```bash\nuv run scripts/address_map.py                      # validate + lint\nuv run scripts/address_map.py --render-cookbook    # regenerate the table\nuv run scripts/address_map.py --check-workspace tf/  # advisory, always exits 0\n```\n\n`address_map.py` is a linter, **not a gate**. `coverage.py` and\n`verify_plan.py` never read the address map, and a test enforces that.\nA wrong entry is caught by `terraform plan` erroring loudly, unlike a\nwrong `benign-drift.yaml` entry which would silently pass a gate. The\nmap is also deliberately incomplete: an address it does not recognise is\na type nobody has mapped yet, which SKILL.md calls the normal case.\n\n<!-- File: skills/fabric-importer/references/address-map.yaml (Tokens: ~7875 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Address and import-ID map — the FIELDED subset of the mapping cookbook.\n#\n# WHY THIS FILE EXISTS\n#\n# `mapping-cookbook.md` carries two different kinds of knowledge:\n#\n#   1. Fielded tuples — (asset type, module, address pattern, import ID,\n#      verification round). Uniform across every entry, mechanically\n#      checkable, and the part that kept being duplicated: the same\n#      address appeared once in the per-type prose and again in a\n#      hand-maintained markdown table, and three families were added\n#      twice because nothing could detect it.\n#\n#   2. Reasoning — ForceNew traps, provider quirks, waiver postures,\n#      capability gaps. Causal prose that becomes strictly worse when\n#      squeezed into a `notes:` string.\n#\n# Only (1) lives here. (2) stays in the cookbook, in prose, on purpose.\n# This file is NOT a specification of what the skill supports and NOT a\n# closed world: a type absent from it is a type nobody has run the\n# method on yet. See \"Step 3b\" in SKILL.md. Never treat a miss here as\n# a reason to stop, and never treat a hit here as a substitute for\n# reading the module source.\n#\n# NOT FROZEN. This file is advisory input to `address_map.py`, which is\n# a linter, not a gate. It is deliberately outside the trust boundary in\n# `integrity.py`: a wrong entry here makes `terraform plan` error\n# loudly, unlike a wrong `benign-drift.yaml` entry which silently passes\n# a gate. Different blast radius, different rigour budget.\n#\n# SCHEMA (enforced by `scripts/address_map.py --validate`)\n#\n#   version: 1\n#   entries:\n#     <slug>:                # stable, kebab-case, immutable once used\n#       resource:      str   # human label, used for the rendered table\n#       module:        str   # 'modules/<name>', or 'raw' for a gap\n#       address:       str   # Terraform address pattern, <placeholder>s\n#       import_id:     str   # provider import ID pattern\n#       asset_type:    str   # CAI asset type, or null when not CAI-modelled\n#       verified:      str   # 'r<N>' round that proved it, or false\n#       note:          str   # optional, ONE line; prose belongs in the cookbook\n#       disambiguation: str  # REQUIRED when an asset_type maps to >1 module\n#\n# INVARIANTS (all machine-enforced)\n#   - `address` is globally unique: no two entries may claim it.\n#   - when one `asset_type` maps to more than one `module`, EVERY entry\n#     sharing that type must carry a non-empty `disambiguation`.\n#   - `verified` is false or matches `r<digits>`.\n#   - `note` is a single line (multi-line reasoning goes in the cookbook).\n#\n# Regenerate the cookbook's rendered table after editing:\n#   uv run scripts/address_map.py --render-cookbook\n\nversion: 1\n\nentries:\n\n  # --- organization -------------------------------------------------\n\n  org-custom-role:\n    resource: Custom role\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_custom_role.roles[\"<id>\"]'\n    import_id: 'organizations/<org>/roles/<id>'\n    asset_type: iam.googleapis.com/Role\n    verified: r3\n\n  org-iam-member:\n    resource: Org IAM member (additive, default)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_member.bindings[\"<key>\"]'\n    import_id: '<org> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  org-iam-binding-authoritative:\n    resource: Org IAM binding (authoritative opt-in)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<org> <role>'\n    asset_type: null\n    verified: r3\n\n  org-iam-binding-conditional:\n    resource: Org IAM binding (cond.)\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_binding.bindings[\"<key>\"]'\n    import_id: '<org> <role> <condition_title>'\n    asset_type: null\n    verified: r3\n\n  org-audit-config:\n    resource: Org audit config\n    module: modules/organization\n    address: 'module.organization.google_organization_iam_audit_config.default[\"<service>\"]'\n    import_id: '<org> <service>'\n    asset_type: null\n    verified: r3\n\n  org-policy:\n    resource: Org policy\n    module: modules/organization\n    address: 'module.organization.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'organizations/<org>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r5\n    disambiguation: 'org-level constraints; folder and project scopes have their own entries'\n\n  org-log-sink:\n    resource: Org log sink\n    module: modules/organization\n    address: 'module.organization.google_logging_organization_sink.sink[\"<name>\"]'\n    import_id: 'organizations/<org>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r3\n    disambiguation: 'org-level sinks; folder sinks are carried by modules/folder'\n\n  org-tag-key:\n    resource: Tag Key\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_key.default[\"<short_name>\"]'\n    import_id: 'tagKeys/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagKey\n    verified: r18\n\n  org-tag-value:\n    resource: Tag Value\n    module: modules/organization\n    address: 'module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]'\n    import_id: 'tagValues/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/TagValue\n    verified: r18\n\n  tag-binding:\n    resource: Tag Binding\n    module: modules/organization\n    address: 'module.<instance>.google_tags_tag_binding.binding[\"<key>\"]'\n    import_id: 'tagBindings/<url-encoded-parent-and-value>'\n    asset_type: cloudresourcemanager.googleapis.com/TagBinding\n    verified: r18\n    note: 'emitted on the container module that owns the bound resource'\n\n  # --- folders ------------------------------------------------------\n\n  folder:\n    resource: Folder\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder.folder[0]'\n    import_id: 'folders/<id>'\n    asset_type: cloudresourcemanager.googleapis.com/Folder\n    verified: r6\n\n  folder-iam-member:\n    resource: Folder IAM member (additive, default)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  folder-iam-binding-authoritative:\n    resource: Folder IAM\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'folders/<id> <role>'\n    asset_type: null\n    verified: r6\n\n  folder-iam-binding-conditional:\n    resource: Folder IAM (cond.)\n    module: modules/folder\n    address: 'module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]'\n    import_id: 'folders/<id> <role> <condition_title>'\n    asset_type: null\n    verified: r6\n\n  folder-org-policy:\n    resource: Folder org policy\n    module: modules/folder\n    address: 'module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'folders/<id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: r6\n    disambiguation: 'folder-scoped constraints; org and project scopes have their own entries'\n\n  folder-log-sink:\n    resource: Folder sink\n    module: modules/folder\n    address: 'module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]'\n    import_id: 'folders/<id>/sinks/<name>'\n    asset_type: logging.googleapis.com/LogSink\n    verified: r6\n    disambiguation: 'folder-level sinks; org sinks are carried by modules/organization'\n\n  # --- projects -----------------------------------------------------\n\n  project:\n    resource: Project\n    module: modules/project\n    address: 'module.project-<key>.google_project.project[0]'\n    import_id: 'projects/<project_id>'\n    asset_type: cloudresourcemanager.googleapis.com/Project\n    verified: r4\n\n  project-service:\n    resource: Project service\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.project_services[\"<api>\"]'\n    import_id: '<project_id>/<api>'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-service-orgpolicy:\n    resource: Project service (orgpolicy)\n    module: modules/project\n    address: 'module.project-<key>.google_project_service.org_policy_service[0]'\n    import_id: '<project_id>/orgpolicy.googleapis.com'\n    asset_type: serviceusage.googleapis.com/Service\n    verified: r4\n\n  project-iam-member:\n    resource: Project IAM member (additive, default)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_member.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  project-iam-binding-authoritative:\n    resource: Project IAM\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]'\n    import_id: '<project_id> <role>'\n    asset_type: null\n    verified: r4\n\n  project-iam-binding-conditional:\n    resource: Project IAM (cond.)\n    module: modules/project\n    address: 'module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]'\n    import_id: '<project_id> <role> <condition_title>'\n    asset_type: null\n    verified: r4\n\n  project-org-policy:\n    resource: Project org policy\n    module: modules/project\n    address: 'module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]'\n    import_id: 'projects/<project_id>/policies/<constraint>'\n    asset_type: orgpolicy.googleapis.com/Policy\n    verified: false\n    disambiguation: 'project-scoped constraints; org and folder scopes have their own entries'\n\n  workload-identity-pool:\n    resource: Workload Identity Pool\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPool\n    verified: r18\n\n  workload-identity-pool-provider:\n    resource: Workload Identity Pool Provider\n    module: modules/project\n    address: 'module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]'\n    import_id: 'projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>'\n    asset_type: iam.googleapis.com/WorkloadIdentityPoolProvider\n    verified: r18\n\n  pam-entitlement:\n    resource: PAM entitlement\n    module: modules/project\n    address: 'module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]'\n    import_id: '<parent>/locations/<location>/entitlements/<entitlement_id>'\n    asset_type: privilegedaccessmanager.googleapis.com/Entitlement\n    verified: false\n    note: '<parent> is organizations/<org>, folders/<id> or projects/<project_id>; grants are excluded, never imported'\n\n  # --- service accounts ---------------------------------------------\n\n  service-account:\n    resource: Service account\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account.service_account[0]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email>'\n    asset_type: iam.googleapis.com/ServiceAccount\n    verified: r8\n\n  service-account-iam-member:\n    resource: SA IAM member (additive, default)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <member>'\n    asset_type: null\n    verified: false\n    note: 'conditional bindings append <condition_title> to the import ID'\n\n  service-account-iam-binding-authoritative:\n    resource: SA IAM binding\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role>'\n    asset_type: null\n    verified: r8\n\n  service-account-iam-binding-conditional:\n    resource: SA IAM binding (cond.)\n    module: modules/iam-service-account\n    address: 'module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/serviceAccounts/<email> <role> <condition_title>'\n    asset_type: null\n    verified: r8\n\n  # --- logging and storage ------------------------------------------\n\n  log-bucket:\n    resource: Log bucket\n    module: modules/logging-bucket\n    address: 'module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]'\n    import_id: 'projects/<project_id>/locations/<location>/buckets/<name>'\n    asset_type: logging.googleapis.com/LogBucket\n    verified: r10\n\n  gcs-bucket:\n    resource: GCS bucket\n    module: modules/gcs\n    address: 'module.<instance>.google_storage_bucket.bucket[0]'\n    import_id: '<project_id>/<bucket_name>'\n    asset_type: storage.googleapis.com/Bucket\n    verified: r12\n    note: 'never import bare <bucket_name> — the project qualifier is a ForceNew trap'\n\n  bigquery-dataset:\n    resource: BigQuery Dataset\n    module: modules/bigquery-dataset\n    address: 'module.<instance>.google_bigquery_dataset.default'\n    import_id: 'projects/<project_id>/datasets/<dataset_id>'\n    asset_type: bigquery.googleapis.com/Dataset\n    verified: r17\n\n  pubsub-topic:\n    resource: Pub/Sub Topic\n    module: modules/pubsub\n    address: 'module.<instance>.google_pubsub_topic.default'\n    import_id: 'projects/<project_id>/topics/<topic_name>'\n    asset_type: pubsub.googleapis.com/Topic\n    verified: r17\n\n  # --- VPC networking -----------------------------------------------\n\n  vpc-network:\n    resource: VPC network\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_network.network[0]'\n    import_id: 'projects/<project_id>/global/networks/<name>'\n    asset_type: compute.googleapis.com/Network\n    verified: r3\n\n  vpc-subnet:\n    resource: VPC subnet\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r3\n    disambiguation: 'ordinary subnets; proxy-only and private-NAT subnets have their own entries'\n\n  vpc-subnet-proxy-only:\n    resource: VPC subnet (proxy-only)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: r9\n    disambiguation: 'purpose REGIONAL_MANAGED_PROXY or GLOBAL_MANAGED_PROXY on the live subnet'\n\n  vpc-subnet-private-nat:\n    resource: VPC subnet (private NAT)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/subnetworks/<name>'\n    asset_type: compute.googleapis.com/Subnetwork\n    verified: false\n    disambiguation: 'purpose PRIVATE_NAT on the live subnet'\n\n  vpc-route-gateway:\n    resource: VPC route (gateway)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_gateway on the live route'\n\n  vpc-route-ilb:\n    resource: VPC route (ilb)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ilb on the live route'\n\n  vpc-route-instance:\n    resource: VPC route (instance)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_instance on the live route'\n\n  vpc-route-ip:\n    resource: VPC route (ip)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_ip on the live route'\n\n  vpc-route-vpn-tunnel:\n    resource: VPC route (vpn tunnel)\n    module: modules/net-vpc\n    address: 'module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/routes/<net>-<key>'\n    asset_type: compute.googleapis.com/Route\n    verified: r9\n    disambiguation: 'next_hop_vpn_tunnel on the live route'\n\n  # --- VPC firewall rules (legacy) ----------------------------------\n\n  vpc-firewall-custom-rule:\n    resource: VPC firewall rule (custom)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/global/firewalls/<rule_name>'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'every rule not generated by the module default-rule toggles'\n\n  vpc-firewall-default-admins:\n    resource: VPC firewall rule (default, admins)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_admins[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-admins'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.admin_ranges, not by custom_rules'\n\n  vpc-firewall-default-http:\n    resource: VPC firewall rule (default, http)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_http[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.http_ranges, not by custom_rules'\n\n  vpc-firewall-default-https:\n    resource: VPC firewall rule (default, https)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_https[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.https_ranges, not by custom_rules'\n\n  vpc-firewall-default-ssh:\n    resource: VPC firewall rule (default, ssh)\n    module: modules/net-vpc-firewall\n    address: 'module.<instance>.google_compute_firewall.allow_tag_ssh[0]'\n    import_id: 'projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh'\n    asset_type: compute.googleapis.com/Firewall\n    verified: false\n    disambiguation: 'generated by default_rules_config.ssh_ranges, not by custom_rules'\n\n  # --- firewall policies --------------------------------------------\n\n  firewall-policy-hierarchical:\n    resource: Hierarchical Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_firewall_policy.hierarchical[0]'\n    import_id: 'locations/global/firewallPolicies/<id>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'org- or folder-attached policy'\n\n  firewall-policy-network-global:\n    resource: Global Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_network_firewall_policy.net_global[0]'\n    import_id: 'projects/<project_id>/global/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, global'\n\n  firewall-policy-network-regional:\n    resource: Regional Network Firewall Policy\n    module: modules/net-firewall-policy\n    address: 'module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]'\n    import_id: 'projects/<project_id>/regions/<region>/firewallPolicies/<name>'\n    asset_type: compute.googleapis.com/FirewallPolicy\n    verified: r7\n    disambiguation: 'project-scoped, regional'\n\n  # --- compute addresses --------------------------------------------\n\n  address-global-psc:\n    resource: Compute address (global PSC)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psc[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'addressType INTERNAL with purpose PRIVATE_SERVICE_CONNECT'\n\n  address-global-psa:\n    resource: Compute address (global PSA)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_global_address.psa[\"<key>\"]'\n    import_id: 'projects/<project_id>/global/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose VPC_PEERING'\n\n  address-regional-internal:\n    resource: Compute address (regional internal)\n    module: modules/net-address\n    address: 'module.<instance>.google_compute_address.internal[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/addresses/<name>'\n    asset_type: compute.googleapis.com/Address\n    verified: r19\n    disambiguation: 'purpose GCE_ENDPOINT or SHARED_LOADBALANCER_VIP'\n\n  # --- routers, NAT, VPN, interconnect ------------------------------\n\n  cloud-router:\n    resource: Cloud Router\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'router whose only attached function is Cloud NAT; a router that also terminates VPN tunnels belongs to modules/net-vpn-ha'\n\n  cloud-nat:\n    resource: Cloud NAT\n    module: modules/net-cloudnat\n    address: 'module.<nat_instance>.google_compute_router_nat.nat'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router>/<nat>'\n    asset_type: compute.googleapis.com/Router\n    verified: r12\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-ha-gateway:\n    resource: HA VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnGateways/<name>'\n    asset_type: compute.googleapis.com/VpnGateway\n    verified: false\n\n  vpn-external-gateway:\n    resource: External VPN Gateway\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]'\n    import_id: 'projects/<project_id>/global/externalVpnGateways/<name>'\n    asset_type: compute.googleapis.com/ExternalVpnGateway\n    verified: false\n\n  vpn-tunnel:\n    resource: VPN Tunnel\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/vpnTunnels/<name>'\n    asset_type: compute.googleapis.com/VpnTunnel\n    verified: false\n    note: 'shared_secret is ForceNew and unreadable — see the cookbook section before emitting'\n\n  vpn-router:\n    resource: Cloud Router (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router.router[0]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'router that terminates VPN tunnels; a NAT-only router belongs to modules/net-cloudnat'\n\n  vpn-router-interface:\n    resource: Cloud Router interface (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]'\n    import_id: '<project_id>/<region>/<router_name>/<interface_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  vpn-router-peer:\n    resource: Cloud Router BGP peer (VPN)\n    module: modules/net-vpn-ha\n    address: 'module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>'\n    asset_type: compute.googleapis.com/Router\n    verified: false\n    disambiguation: 'sub-resource with no CAI asset of its own; claimed by the parent Router key'\n\n  interconnect-attachment:\n    resource: Interconnect Attachment\n    module: modules/net-vlan-attachment\n    address: 'module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/regions/<region>/interconnectAttachments/<name>'\n    asset_type: compute.googleapis.com/InterconnectAttachment\n    verified: false\n\n  # --- Cloud DNS ----------------------------------------------------\n\n  dns-managed-zone:\n    resource: DNS zone\n    module: modules/dns\n    address: 'module.<instance>.google_dns_managed_zone.dns_managed_zone[0]'\n    import_id: 'projects/<project_id>/managedZones/<zone>'\n    asset_type: dns.googleapis.com/ManagedZone\n    verified: r12\n\n  dns-record-set:\n    resource: DNS recordset\n    module: modules/dns\n    address: 'module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]'\n    import_id: 'projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>'\n    asset_type: dns.googleapis.com/ResourceRecordSet\n    verified: false\n    note: 'for_each key is \"<type> <name>\" — type first, single space, trailing dot on <name>'\n\n  dns-response-policy:\n    resource: DNS Response Policy\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy.default[0]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>'\n    asset_type: dns.googleapis.com/ResponsePolicy\n    verified: false\n\n  dns-response-policy-rule:\n    resource: DNS Response Policy rule\n    module: modules/dns-response-policy\n    address: 'module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]'\n    import_id: 'projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>'\n    asset_type: dns.googleapis.com/ResponsePolicyRule\n    verified: false\n\n  # --- KMS ----------------------------------------------------------\n\n  kms-keyring:\n    resource: KMS KeyRing\n    module: modules/kms\n    address: 'module.<instance>.google_kms_key_ring.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>'\n    asset_type: cloudkms.googleapis.com/KeyRing\n    verified: r13\n\n  kms-crypto-key:\n    resource: KMS CryptoKey\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key.default[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>'\n    asset_type: cloudkms.googleapis.com/CryptoKey\n    verified: r13\n\n  kms-crypto-key-iam:\n    resource: KMS CryptoKey IAM\n    module: modules/kms\n    address: 'module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>'\n    asset_type: null\n    verified: r13\n\n  # --- Certificate Authority Service --------------------------------\n\n  cas-ca-pool:\n    resource: CAS CA Pool\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool.default[0]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name>'\n    asset_type: privateca.googleapis.com/CaPool\n    verified: r14\n\n  cas-ca-pool-iam:\n    resource: CAS CA Pool IAM\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role>'\n    asset_type: null\n    verified: false\n\n  cas-ca-pool-iam-conditional:\n    resource: CAS CA Pool IAM (cond.)\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>'\n    asset_type: null\n    verified: false\n\n  cas-certificate-authority:\n    resource: Certificate Authority\n    module: modules/certificate-authority-service\n    address: 'module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]'\n    import_id: 'projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>'\n    asset_type: privateca.googleapis.com/CertificateAuthority\n    verified: false\n\n  # --- VPC Service Controls -----------------------------------------\n\n  vpcsc-access-policy:\n    resource: VPC-SC Access Policy\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_policy.default[0]'\n    import_id: '<policy_id>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessPolicy\n    verified: r15\n    note: 'import ID is the bare numeric policy id, with no accessPolicies/ prefix'\n\n  vpcsc-access-level:\n    resource: VPC-SC Access Level\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/accessLevels/<level_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/AccessLevel\n    verified: r15\n\n  vpcsc-service-perimeter:\n    resource: VPC-SC Service Perimeter\n    module: modules/vpc-sc\n    address: 'module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]'\n    import_id: 'accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>'\n    asset_type: identity.accesscontextmanager.googleapis.com/ServicePerimeter\n    verified: r15\n\n  # --- Network Connectivity Center ----------------------------------\n\n  ncc-hub:\n    resource: NCC Hub\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_hub.hub[0]'\n    import_id: 'projects/<project_id>/locations/global/hubs/<hub_name>'\n    asset_type: networkconnectivity.googleapis.com/Hub\n    verified: false\n\n  ncc-spoke-ra:\n    resource: NCC spoke (router appliance)\n    module: modules/ncc-spoke-ra\n    address: 'module.<instance>.google_network_connectivity_spoke.spoke_ra'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: false\n    disambiguation: 'linked_router_appliance_instances spokes only'\n\n  ncc-spoke-raw:\n    resource: NCC spoke (raw — capability gap)\n    module: raw\n    address: 'google_network_connectivity_spoke.<instance>'\n    import_id: 'projects/<project_id>/locations/<location>/spokes/<spoke_name>'\n    asset_type: networkconnectivity.googleapis.com/Spoke\n    verified: r12\n    disambiguation: 'linked-VPC and linked-VPN spokes, which no Fabric module carries'\n\n<!-- File: skills/fabric-importer/references/cai-blind-spots.md (Tokens: ~5279 | Category: anthropic-skill) -->\n# CAI blind spots — where the denominator is incomplete\n\nThe completeness gate is only as strong as Cloud Asset Inventory's\ncoverage. An asset type CAI does not model is invisible to BOTH gates\nsimultaneously (not enumerated → not required by coverage; not emitted →\nnot diffed by plan). Treat this list as living documentation: verify and\nextend it per engagement.\n\nThere are three distinct blind spots, and they need different remedies:\n\n1. **CAI does not model the type at all.** The classic case, and what\n   most of this document is about. Remedy: enumerate it by other means\n   and merge it into the same denominator.\n2. **CAI models the type under a different NAME on the surface you\n   queried.** Rarer, much quieter, and not a blind spot in CAI at all —\n   a blind spot in the question. See\n   [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies).\n3. **CAI models the type but omits a FIELD a consumer depends on.**\n   Quietest of all: the sweep succeeds and the payload simply lacks the\n   attribute. See [Field-level blind spots](#field-level-blind-spots).\n\n## The rule\n\n**CAI is the DEFAULT source of the denominator, never the boundary of\nit.** Anything CAI does not support is retrieved by other means and\nmerged into the same denominator, so the completeness gate still has to\naccount for it. \"CAI cannot see it\" is not a reason for an asset to be\nabsent; it is a reason to enumerate it differently.\n\nThe ladder, in order of preference:\n\n1. **CAI** (`gcloud asset list`, `asset search-all-resources`) — default,\n   because one sweep covers a whole hierarchy and returns ancestry.\n2. **`gcloud <service> list|describe`** — the preferred alternative.\n   Read-only, already installed, already authenticated as the run's\n   read-only identity, and machine-readable with `--format=json`.\n   Declare it in the manifest as a native enumerator (below) so the\n   entries land in `inventory.json` like any others.\n3. **The service REST API** (`curl` with `gcloud auth print-access-token`)\n   — only where gcloud has no surface at all. Enumerate out of band,\n   record the exact call in the run report, and treat any assets found\n   as in scope for mapping.\n4. **A signed waiver plus a run-report entry** — the last resort, and a\n   deliberate, attributed decision. \"Not enumerable\" is a fact to\n   publish, never a silence.\n\n`inventory.py` walks steps 1→2 by itself. It ships a table of built-in\nenumerators (`NATIVE_ENUMERATORS`) for types known to be absent from the\nCAI catalogue: declaring such a type in the manifest is enough, and the\ntool announces that it took the gcloud route. A declared type that CAI\nrejects and no enumerator covers fails the run with the remedy, instead\nof a generic enumeration failure. Steps 3 and 4 it cannot enforce — that\nis the operator's honesty, and the run report is where it is spent.\n\n## Surface-dependent type taxonomies\n\nCAI does not have one asset-type taxonomy. It has two, and for a handful\nof Compute families they disagree:\n\n- the **list / export / query / monitor** surface (`gcloud asset list`,\n  this tool's primary sweep) splits a family by scope into **separate\n  asset types**;\n- the **search / analysis** surface (`gcloud asset\n  search-all-resources`) folds those same resources into a **single\n  unified type**.\n\nGoogle documents this per type on the\n[supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types),\nbut only in the per-type prose, which is easy to read past:\n\n> `compute.googleapis.com/Address` — Returns global and regional\n> addresses in the search and analysis APIs, and only regional addresses\n> in the list, export, query, and monitor APIs.\n>\n> `compute.googleapis.com/GlobalAddress` — Not available in the analysis\n> and search APIs. Use `compute.googleapis.com/Address` instead in the\n> search and analysis APIs.\n\nThe documented pairs:\n\n| Declared (search taxonomy) | List-surface sibling swept alongside it |\n|---|---|\n| `compute.googleapis.com/Address` | `compute.googleapis.com/GlobalAddress` |\n| `compute.googleapis.com/BackendService` | `compute.googleapis.com/RegionBackendService` |\n| `compute.googleapis.com/Disk` | `compute.googleapis.com/RegionDisk` |\n| `compute.googleapis.com/ForwardingRule` | `compute.googleapis.com/GlobalForwardingRule` |\n\n### Reproducing it, if you ever need to\n\nThe silent-gap condition needs a family whose declared type yields\n**non-zero** while still being incomplete: at least one REGIONAL address\nand at least one GLOBAL address, in the same in-scope project. With only\nglobal addresses present the declared type yields zero, the pre-existing\nzero-yield warning fires, and the tool is loud — a real bug, but not\nTHIS bug. (With several scopes, remember the aggregate warning can stay\nquiet while one scope's own declaration yielded nothing — the per-scope\n`_meta.scopes[].zero_yield_types` record is the loud one there.) A test estate built only from a freshly created global address\ntherefore validates that siblings are swept, and validates nothing about\nthe silence.\n\n### Why this one is worse than an unsupported type\n\nAn unsupported type fails loudly. This one fails **silently, past every\nguard the tool has** — which is why it needed a mechanism rather than a\nsharper error message. Live-run finding: a global Private Service\nConnect address never entered the denominator, because\n\n- the declared type IS supported by `asset list`, so the\n  unsupported-type fallback to `search-all-resources` never fired;\n- the sweep SUCCEEDED, so nothing landed in `SWEEP_FAILURES`;\n- it returned 33 regional addresses, so the zero-yield warning stayed\n  quiet;\n- the asset was never collected, so `apply_level_filter`'s `unknown`\n  safety net never saw it either.\n\nThe operator had already hand-written the correct `import {}` block, so\nthe only signal that fired was `coverage.py` reporting it as an **orphan\nimport block** — and an orphan was read as a coverage-map problem and\nwaived, rather than as evidence that the denominator was short.\n\n**An orphan import block for a resource you know is live is evidence the\ndenominator is short.** Investigate the enumeration before waiving it;\na waiver signed over a short denominator makes the gap permanent and\nattributed to a human.\n\n### What `inventory.py` does\n\n`CAI_SPLIT_TYPES` is a frozen table of the pairs above. Declaring the\nunified type sweeps its list-surface siblings too, and the siblings'\nentries are **retyped back to the declared type**, so one manifest line\nmeans \"all addresses\" — which is what an operator reading the\nsupported-types page will believe it means. This costs **zero extra API\ncalls**: `asset list` takes a comma-separated `--asset-types`, so the\nsiblings ride along in the existing sweep.\n\nNothing is laundered by the retyping:\n\n- each retyped entry carries `cai_list_type` with the type CAI actually\n  returned it as, and that field travels into `coverage.py`'s worklist;\n- `_meta.split_type_sweeps` records declared type, list-surface type,\n  scope and the RAW swept count — what CAI returned, before the\n  subtree/deleted/level filters;\n- collection prints a NOTICE naming every sibling that contributed, and\n  reconciles swept against in-the-denominator itself (e.g. `3 swept …,\n  2 in the denominator (1 excluded by scope/level/deleted filters)`) —\n  the filters apply to retyped entries like to any other asset, and the\n  subtraction is the tool's to show, not the reader's to derive.\n\nA sibling the manifest declares **in its own right** is not remapped: an\noperator who names `compute.googleapis.com/GlobalAddress` explicitly\nwants it accounted as itself, and remapping would make their\nper-declared-type yield read zero.\n\n### Checking the table against live CAI\n\nThe table is a frozen snapshot of a document Google changes. A new split\n— or a renamed pairing — would put the tool straight back into the\nfailure mode the table closes, so there is a probe:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra `search-all-resources` call per scope, restricted to declared\nsplit types. It compares against the **raw** sweep output, before the\nmanifest's subtree filters, because the question under test is taxonomy\nand not scope. Anything the search surface returns that the list sweep\ndid not is **fatal** (exit 3) with the offending resource names: the\ntable is stale and the denominator is incomplete.\n\nThe probe is opt-in, for two reasons: it costs a call per scope, and it\nimports the search index's propagation lag as a possible false positive.\nRun it at least once per engagement, and quote the result in the run\nreport. `_meta.split_parity` is **empty** when the probe did not run; a\nprobe that ran and found nothing is a RECORD whose `only_in_search` is\nempty. Read the record, not the key — \"not checked\" and \"checked, clean\"\nare different claims and must not be reported as the same one.\n\nIf the probe fires, identify the list-surface type of the missing assets\n(`gcloud asset list` with no `--asset-types`, then match on name) and\nreport it: `CAI_SPLIT_TYPES` needs a new entry, which is a reviewed\nchange to a frozen file, not a local patch.\n\nCAI **retiring** a split is the convergence this table is waiting for.\nA sibling type that `asset list` no longer recognises is therefore\nreported as a possibly-stale table, not as a failed run — a sibling is\ntool-supplied, not operator-declared, so it must never fail someone\nelse's collection.\n\n## Field-level blind spots\n\nA missing field in a CAI payload is asymmetric to an unsupported asset type:\n- An **unsupported type fails loudly**: CAI returns `INVALID_ARGUMENT`, the run\n  stops, and a remedy is displayed.\n- A **missing field fails silently**: `.get()` returns `None`, a filter\n  predicate answers \"no\", and the asset unexpectedly stays in or drops out\n  of the denominator while the run appears healthy.\n\nA filter predicate must never depend on a field whose presence in the live\npayload has not been verified. When a discriminator field is unavailable or\nunverified in CAI, the filter must **fail open** — keep the asset in the\ndenominator for a human to waive, never drop it silently.\n\n## Built-in enumerators\n\n| Asset type | How it is enumerated | Status |\n|---|---|---|\n| `iam.googleapis.com/DenyPolicy` | `gcloud iam policies list --kind=denypolicies --attachment-point=cloudresourcemanager.googleapis.com/<container>` | Command shape verified against gcloud 576; payload shape not yet exercised live |\n\nThe table is short on purpose. To qualify, an enumerator has to be\nhierarchy-container-scoped, read-only, JSON-emitting, and produce a key\nthat is stable and unique. Most non-CAI types fail one of those —\nusually the first — and stay in the blind-spot table below, enumerated\nby hand and named in the run report.\n\nThe table is frozen, like the rest of `inventory.py`: it decides part of\nwhat the denominator contains. Nothing about extending it requires\nediting a frozen file, though — a manifest block covers a type the table\ndoes not, and overrides an entry when an operator knows better. An\nenumerator earned in an engagement should come back as a reviewed change\nto the table, so the next run starts with it.\n\n## Declaring a native enumerator\n\nA manifest type entry may carry an `enumerate:` block. The type is then\nnever sent to CAI; the declared gcloud command runs once per in-scope\ncontainer at the type's `levels`, and its JSON becomes inventory\nentries:\n\n```yaml\n    types:   # a scope's own list — the only position types are declared in\n      # IAM deny policies: managed by modules/organization,\n      # modules/folder and modules/project, and absent from the CAI\n      # catalogue entirely.\n      - type: iam.googleapis.com/DenyPolicy\n        levels: [organization, folder]\n        enumerate:\n          # `gcloud` is implicit and is the only executable a manifest\n          # can name. The tool appends --format=json and one container\n          # argument.\n          command: [iam, policies, list, --kind=denypolicies]\n          # Optional: the shape of that container argument, when the\n          # command does not take --organization/--folder/--project=<id>.\n          # Fields: {container} (e.g. organizations/1) and\n          # {container_id} (1).\n          container_arg: '--attachment-point=cloudresourcemanager.googleapis.com/{container}'\n          # Key template. Fields: {container}, {container_id} and\n          # {item.<dotted.path>} into each returned JSON object. Where\n          # the type has a CAI name format, mirror it, so entries from\n          # the two sources dedupe instead of double-counting.\n          key: '//iam.googleapis.com/{container}/denypolicies/{item.name}'\n```\n\nDeclared in one scope's list, the command runs only for that scope's\ncontainers; another scope may declare the same type with a different\n`enumerate:` block. What it may not do is inherit: per-scope lists\nnever inherit anything, so the block is repeated in every scope that\nneeds it.\n\n**The mechanism is per hierarchy container**, because that is the shape\nof `levels` — the type entry's `levels` intersected with its scope's.\nAn empty intersection is refused when the manifest is parsed (a\nper-scope declaration that can never fire is a mistake, not a\nleftover), unless the entry lists `unknown`. A command that enumerates inside another\nresource — `gcloud storage managed-folders list gs://BUCKET` — cannot be\nexpressed as an `enumerate:` block: there is no container flag to fill\nin. Those types are enumerated out of band and named in the run report\n(step 3 of the ladder), or waived deliberately. Do not force them into a\nblock by hard-coding one bucket; a container argument that does not vary\nsweeps one place and leaves the rest silently unenumerated, which is why\nthe tool refuses it.\n\nRun the command by hand once before committing the block: the key\ntemplate has to match the payload the command actually returns, and a\nfirst run is the cheapest place to find out that it does not. A field\nthat is absent is a hard failure, and a yield of zero is reported\nloudly — neither is silent, but neither belongs in a run either.\n\nGuard rails, all fail-closed, all for the same reason — a native\nenumerator is a hand-written piece of the denominator, and the\ndenominator is the one thing in this skill nobody is allowed to shrink\nquietly:\n\n- the command must end in a read-only verb (`list`, `describe`, `get`,\n  `search`); anything else is refused before a single call is made;\n- `--format`, `--filter`, `--limit`, `--page-size`, `--flatten`,\n  `--sort-by` and `--uri` are refused. Narrowing belongs in the\n  manifest's `scope` block, where it is reviewed; a `--filter` inside an\n  enumerator shrinks the denominator with no trace;\n- the command may not select an identity — impersonation is set once for\n  the whole run via `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`;\n- a key template that renders the same key for two items, or that\n  references a field the payload does not have, is a hard failure. Two\n  assets collapsing onto one key is a shrunken denominator with a green\n  gate;\n- every native sweep is stamped into `inventory.json`'s `_meta`\n  (`native_sweeps`: type, verbatim command, container, yield) and\n  announced on stderr. A reviewer can re-run each command by hand.\n\nThe manifest is human-owned and committed at the scope-approval gate,\nwhich is what makes an operator-supplied command acceptable at all. An\nagent may draft one; a human signs it, exactly as with waivers.\n\n## Known or suspected gaps (verify per engagement)\n\n| Area | Concern | Mitigation |\n|---|---|---|\n| Service coverage | CAI supports several hundred asset types but not every GCP service/resource; niche or very new resources may be absent | Check the [CAI supported types list](https://cloud.google.com/asset-inventory/docs/supported-asset-types) for every service the user cares about; for uncovered types, declare a native enumerator (above) so they still enter the denominator |\n| Wrong type string | A type string that does not exist in the catalogue is not a blind spot but a typo, and it presents as one. `gcloud asset list` answers `INVALID_ARGUMENT: No supported asset type matches: <type>` | `inventory.py` classifies that error separately from a permission failure and stops with instructions. Live example: `logging.googleapis.com/OrganizationSettings` does not exist — CAI models the Logs Router settings singleton as `logging.googleapis.com/Settings` (supported at `organization` and `folder` levels only; not indexed at `project` level) |\n| Right type string, wrong surface | A type that is correct for `search-all-resources` can cover only PART of its family under `asset list`, which splits some Compute families by scope into separate types. No error, non-zero yield, silent gap. Live example: a global PSC address typed `compute.googleapis.com/GlobalAddress` by the list surface, absent from a `compute.googleapis.com/Address` sweep | **Handled automatically** for the documented pairs — `CAI_SPLIT_TYPES` sweeps the siblings and accounts them under the declared type. Run `--verify-search-parity` once per engagement to check the frozen table against live CAI; see [Surface-dependent type taxonomies](#surface-dependent-type-taxonomies) |\n| IAM deny policies | `iam.googleapis.com/DenyPolicy` is not a CAI asset type. Fabric manages deny policies at all three container levels (`iam_deny_policies`) | **Handled automatically** — built-in enumerator; just declare the type |\n| Log exclusions | `logging.googleapis.com/LogExclusion` is not a CAI asset type, and current `gcloud` has no `logging exclusions` group either — the ladder falls through to the REST API (`v2/{parent}/exclusions`). Fabric manages exclusions in `modules/organization`, `modules/folder` and `modules/project` | Enumerate out of band, map them, and record the enumeration method in the run report; do not let their absence from CAI read as absence from the estate |\n| Org-policy content-type lag | `--content-type=org-policy` can lag behind newly introduced v2 constraints; the `orgpolicy.googleapis.com/Policy` resource asset stream is more complete | `inventory.py` merges both CAI streams; keep cross-checking counts with `gcloud org-policies list` |\n| Org-policy dry-run specs | CAI `orgpolicy.googleapis.com/Policy` resource stream returns policies where `spec` is present (with or without `dryRunSpec`), but completely **omits dry-run-only policies** where `spec` is unset | `inventory.py collect` sweeps `gcloud org-policies list` per in-scope container and merges by key, so dry-run-only policies enter the denominator automatically |\n| IAM conditions | The `iam-policy` content type returns version-3 policies with conditional bindings intact | Verified in live testing |\n| IAM on leaf assets | `inventory.py` restricts the `iam` pseudo-type to container assets (`Organization`, `Folder`, `Project`); leaf IAM needs explicit per-type manifest entries (`iam: true`) | Documented in the manifest reference |\n| Audit configs | `auditConfigs` block is present and fully preserved in the CAI `iam-policy` payload | Verified in live testing |\n| Deleted / pending-delete resources | CAI reflects live state; soft-deleted roles or pending-delete projects may or may not appear | Decide policy per type; document in the run report |\n| Propagation lag | CAI can lag live changes by minutes | Re-run inventory immediately before the final gate pass; treat count mismatches with service APIs as failures, not noise |\n| Cloud Storage below the bucket | The Cloud Storage section of the CAI catalogue contains exactly one type, `storage.googleapis.com/Bucket`. Objects (`google_storage_bucket_object`), managed folders (`google_storage_managed_folder`) and managed-folder IAM are not modelled | `gcloud storage ls` / `gcloud storage managed-folders list` take a bucket URL, not a container flag, so they do not fit an `enumerate:` block. Enumerate per bucket out of band, map, and record the method in the run report |\n| Terraform-only resources | Some `google_*` resources correspond to no asset at all. `google_project_service_identity` triggers creation of a Google-managed service agent: the state row is real, the CAI asset is not, and Fabric's `modules/project` handles service agents internally | Map through the owning module and note it in the report; there is nothing to enumerate and nothing to waive |\n| Leaf IAM read as a missing type | `google_storage_bucket_iam_binding`, `google_tags_tag_value_iam_binding` and friends are not asset types. They are the `iam-policy` content type on an asset CAI already models, and enter the denominator via `iam: true` on the PARENT type entry | Never waive these as unsupported. `manifest_from_state.py` maps the common ones to their parent with the flag already set |\n| Data-plane / child resources | Some child resources (e.g. per-bucket notification configs, dataset ACL entries) are attributes of the parent in CAI, not assets | The plan gate covers them once the parent is imported; note them in the report if the user expects per-child coverage |\n| Access Context Manager | CAI `asset list --content-type=resource` rejects ACM types, requiring `asset search-all-resources`; `parentFullResourceName` lacks standard container prefixes | Handled in `inventory.py` via dedicated search and level classification |\n\n## Rules\n\n1. When a scope's `types:` list declares a type, confirm it appears in\n   the CAI supported-types list. If it does not, it gets a native enumerator, an\n   out-of-band enumeration recorded in the report, or a signed waiver —\n   one of the three, always. Never a quiet removal from the manifest.\n2. Any count mismatch between CAI and a service API is a **failure to\n   investigate**, never noise to average over. The same applies between\n   CAI's own two surfaces: `asset list` and `search-all-resources`\n   disagreeing about a type is a taxonomy split to identify, not a\n   discrepancy to pick a winner from.\n3. An **orphan import block** reported by `coverage.py` for a resource\n   you know is live is evidence the denominator is short. Investigate\n   the enumeration first; a waiver over a short denominator makes the\n   gap permanent and signs a human's name to it.\n4. Every entry that did not come from CAI is named in the run report,\n   with the command that produced it. `_meta.native_sweeps` in\n   `inventory.json` carries this for declared enumerators;\n   `_meta.split_type_sweeps` carries entries that came from CAI under a\n   different type than the one declared; out-of-band enumeration is on\n   you to quote. Per-scope yields live in\n   `_meta.scopes[].declared_types` / `zero_yield_types` — the aggregate\n   table can hide a scope whose own declaration yielded nothing.\n5. A scope's `types:` list narrows the denominator, so it belongs to\n   the Scope Approval gate like any other narrowing. A convergence\n   claim is bounded per scope, not per manifest — report it that way.\n6. New blind spots discovered during an engagement get added here (this\n   file is reference documentation, not a frozen tool).\n\n<!-- File: skills/fabric-importer/references/inferring-manifests-from-state.md (Tokens: ~2718 | Category: anthropic-skill) -->\n# Inferring Import Manifests from Terraform State\n\nWhen migrating or refactoring existing Google Cloud infrastructure managed by Terraform into Cloud Foundation Fabric (CFF), inferring the `import-manifest.yaml` directly from existing Terraform state file(s) (`.tfstate`) is the most precise and deterministic way to define the import boundary.\n\n---\n\n## Why State-Driven Manifest Generation?\n\n| Approach | Strength | Trade-off / Note |\n| :--- | :--- | :--- |\n| **State-Driven Inference (`manifest_from_state.py`)** | Exactly matches the existing Terraform management footprint. No risk of accidentally pulling unmanaged live assets into the stage scope. | Requires access to state files (`terraform state pull` or GCS bucket access). |\n| **Live Asset Survey (`inventory.py survey` + `manifest_init.py`)** | Discovers all live cloud assets, including unmanaged or out-of-band resources. | Broader denominator; requires manually waiving pre-existing or external assets. |\n\n---\n\n## Workflow\n\n### 1. Retrieve the Terraform State(s)\n\nPull the state files for the target stages or root modules:\n\n```bash\n# Example for FAST Stage 0\ngsutil cp gs://<iac-core-project>-iac-org-state/default.tfstate stage-0.tfstate\n\n# Or pull via Terraform CLI\nterraform -chdir=path/to/stage-0 state pull > stage-0.tfstate\n```\n\nFor multi-stage environments (e.g., FAST stages 0, 1, 2), you can pull states for all relevant stages:\n```bash\nterraform -chdir=fast/stages/0-org-setup state pull > stage-0.tfstate\nterraform -chdir=fast/stages/1-vpcsc state pull > stage-1.tfstate\nterraform -chdir=fast/stages/2-networking state pull > stage-2-networking.tfstate\nterraform -chdir=fast/stages/2-security state pull > stage-2-security.tfstate\n```\n\n### 2. Generate the Manifest\n\nRun `manifest_from_state.py`:\n\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate \\\n  --out import-manifest.yaml\n```\n\nTo synthesize a manifest across multiple stages (note `--force`: the\nmanifest is human-owned and gate-relevant, so it is never overwritten\nsilently — use `--out -` to review on stdout first):\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-0.tfstate stage-1.tfstate stage-2-networking.tfstate stage-2-security.tfstate \\\n  --out import-manifest.yaml --force\n```\n\nAll state files must belong to the same organization: a state spanning\nseveral is refused rather than silently resolved to one of them.\n\nWhen running on an isolated stage state (e.g. `stage-2-networking.tfstate`) that contains resources across multiple folders without including an organization resource in that state, specify the intended scope root with `--root`:\n```bash\nuv run scripts/manifest_from_state.py \\\n  --state stage-2-networking.tfstate \\\n  --root organizations/123456789012 \\\n  --out import-manifest.yaml\n```\n\n### 3. Review and Collect\n\nReview the generated `import-manifest.yaml`, then collect the live denominator:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\n---\n\n## Type & Level Mapping Reference\n\nThe inference script automatically maps Terraform `google_*` resources to CAI asset types and container levels:\n\n| Terraform Resource Type | Manifest `type` | Container Levels | Special Handling |\n| :--- | :--- | :--- | :--- |\n| `google_organization_iam_*` | `iam` | `[organization]` | Authoritative & additive bindings |\n| `google_folder_iam_*` | `iam` | `[folder]` | Folder IAM |\n| `google_project_iam_*` | `iam` | `[project]` | Project IAM |\n| `google_service_account_iam_*` | `iam.googleapis.com/ServiceAccount` | `[project]` | Emits `iam: true` on SA type |\n| `google_org_policy_policy`, `google_organization_policy` | `org-policy` | `[organization, folder, project]` | Inferred from parent attribute |\n| `google_organization_iam_custom_role` | `iam.googleapis.com/Role` | `[organization]` | Org custom roles |\n| `google_project_iam_custom_role` | `iam.googleapis.com/Role` | `[project]` | Project custom roles |\n| `google_logging_organization_sink` | `logging.googleapis.com/LogSink` | `[organization]` | Org log sinks |\n| `google_logging_folder_sink` | `logging.googleapis.com/LogSink` | `[folder]` | Folder log sinks |\n| `google_logging_project_sink` | `logging.googleapis.com/LogSink` | `[project]` | Project log sinks |\n| `google_logging_project_bucket_config`| `logging.googleapis.com/LogBucket` | `[project]` | Project log buckets |\n| `google_logging_organization_settings` | `logging.googleapis.com/Settings` | `[organization]` | Logs Router settings singleton (CAI has no `OrganizationSettings` type) |\n| `google_logging_folder_settings` | `logging.googleapis.com/Settings` | `[folder]` | Logs Router settings singleton |\n| `google_essential_contacts_contact` | `essentialcontacts.googleapis.com/Contact` | inferred from `parent` | Essential contacts |\n| `google_storage_bucket_iam_*` | `storage.googleapis.com/Bucket` | `[project]` | Emits `iam: true` on the bucket type |\n| `google_tags_tag_value_iam_*` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Emits `iam: true` on the tag value type |\n| `google_folder` | `cloudresourcemanager.googleapis.com/Folder` | `[organization, folder]` | Top-level and nested folders |\n| `google_project` | `cloudresourcemanager.googleapis.com/Project` | `[organization, folder]` | Projects |\n| `google_storage_bucket` | `storage.googleapis.com/Bucket` | `[project]` | GCS Buckets |\n| `google_service_account` | `iam.googleapis.com/ServiceAccount` | `[project]` | Service Accounts |\n| `google_tags_tag_key` | `cloudresourcemanager.googleapis.com/TagKey` | `[organization]` | Resource Manager Tag Keys |\n| `google_tags_tag_value` | `cloudresourcemanager.googleapis.com/TagValue` | `[organization]` | Tag Values |\n| `google_tags_tag_binding` | `cloudresourcemanager.googleapis.com/TagBinding` | `[organization, folder, project]` | Tag Bindings |\n| `google_project_service` | `serviceusage.googleapis.com/Service` | `[project]` | Enabled APIs |\n| `google_access_context_manager_*` | `identity.accesscontextmanager.googleapis.com/*` | `[organization]` | Access Policies, Perimeters, Levels |\n| `google_compute_network` | `compute.googleapis.com/Network` | `[project]` | VPCs |\n| `google_compute_subnetwork` | `compute.googleapis.com/Subnetwork` | `[project]` | Subnets |\n| `google_compute_global_address` | `compute.googleapis.com/Address` | `[project]` | Global IP Addresses |\n| `google_compute_router` | `compute.googleapis.com/Router` | `[project]` | Cloud Routers & NAT |\n| `google_compute_firewall` | `compute.googleapis.com/Firewall` | `[project]` | Firewall rules |\n| `google_compute_firewall_policy` | `compute.googleapis.com/FirewallPolicy` | `[organization, folder]` | Hierarchical Firewall Policies |\n| `google_compute_network_firewall_policy` | `compute.googleapis.com/NetworkFirewallPolicy` | `[project]` | Network Firewall Policies |\n| `google_compute_ha_vpn_gateway`, `google_compute_vpn_gateway` | `compute.googleapis.com/VpnGateway` | `[project]` | HA & Classic VPN Gateways |\n| `google_compute_external_vpn_gateway` | `compute.googleapis.com/ExternalVpnGateway` | `[project]` | External VPN Gateways |\n| `google_compute_vpn_tunnel` | `compute.googleapis.com/VpnTunnel` | `[project]` | VPN Tunnels |\n| `google_dns_managed_zone` | `dns.googleapis.com/ManagedZone` | `[project]` | Cloud DNS Zones |\n| `google_dns_response_policy` | `dns.googleapis.com/ResponsePolicy` | `[project]` | Cloud DNS Response Policies |\n| `google_network_connectivity_hub` | `networkconnectivity.googleapis.com/Hub` | `[project]` | NCC Hubs |\n| `google_network_connectivity_spoke` | `networkconnectivity.googleapis.com/Spoke` | `[project]` | NCC Spokes |\n| `google_storage_managed_folder` | `storage.googleapis.com/ManagedFolder` | `[project]` | Storage Managed Folders |\n| `google_storage_managed_folder_iam_*` | `storage.googleapis.com/ManagedFolder` | `[project]` | Emits `iam: true` on ManagedFolder |\n| `google_kms_key_ring` | `cloudkms.googleapis.com/KeyRing` | `[project]` | KMS Key Rings |\n| `google_kms_crypto_key` | `cloudkms.googleapis.com/CryptoKey` | `[project]` | KMS Keys |\n| `google_bigquery_dataset` | `bigquery.googleapis.com/Dataset` | `[project]` | BigQuery Datasets |\n| `google_pubsub_topic` | `pubsub.googleapis.com/Topic` | `[project]` | Pub/Sub Topics |\n| `google_pubsub_subscription` | `pubsub.googleapis.com/Subscription` | `[project]` | Pub/Sub Subscriptions |\n| `google_secret_manager_secret` | `secretmanager.googleapis.com/Secret` | `[project]` | Secret Manager |\n| `google_iam_workload_identity_pool` | `iam.googleapis.com/WorkloadIdentityPool` | `[project]` | WIF Pools |\n| `google_iam_workload_identity_pool_provider` | `iam.googleapis.com/WorkloadIdentityPoolProvider` | `[project]` | WIF Identity Providers |\n\n### Types this table does not list\n\nA `google_*` type absent from the table is reported as unmapped, and the\nwarning is precise about what that means: **this tool has no static\nTerraform-to-CAI row for it**. It is not a statement about Cloud Asset\nInventory. Triage each one — CAI may model it (add the type by hand), it\nmay be leaf IAM on a type CAI models (`iam: true` on the parent), CAI may\ngenuinely not model it (`enumerate:` block or out-of-band enumeration,\nsee [cai-blind-spots.md](./cai-blind-spots.md)), or it may be deliberately\nout of scope (signed waiver). Anything you resolve is worth sending back\nas a `TF_TYPE_MAP` entry.\n\n---\n\n## Multi-Scope Partitioning\n\nWhen resources span both Organization/Folder governance and project-contained resources, `manifest_from_state.py` produces a multi-scope configuration:\n\n```yaml\nscopes:\n  - name: org-foundation\n    root: organizations/123456789012\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n\n  - name: stage-projects\n    root: organizations/123456789012\n    levels: [project]\n    include:\n      - projects/111111111111   # prj-prod-audit-logs-0\n      - projects/222222222222   # prj-prod-iac-core-0\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: iam.googleapis.com/ServiceAccount\n        levels: [project]\n        iam: true\n```\n\nThis prevents project-level queries from accidentally scanning every unrelated project in the organization while still managing org-level policies and folders.\n\nEvery scope carries its own `types:` list; the generator filters the inferred types per scope (a type whose `levels` cannot fire at a scope's levels is left out, because `inventory.py` refuses dead per-scope declarations) but keeps each entry's full inferred `levels` — the intersection with the scope's `levels` happens at collect time, so the entry reads the same in every scope that carries it. Narrowing a generated list further — dropping a type or a level from one scope but not another — is a normal human refinement of the draft. Regenerating the manifest discards such refinements, so refine after generation, not before.\n\n<!-- File: skills/fabric-importer/references/manifest-migration.md (Tokens: ~1863 | Category: anthropic-skill) -->\n# Manifest migration — from the retired grammar to scopes-only\n\nThe import-manifest grammar changed once, and this file is the record of\nit. If `inventory.py` refused your manifest with *\"manifest declares\nretired top-level key(s)\"*, you are holding a manifest written for the\nold grammar; this page is the complete migration path.\n\n## What changed, and why\n\n**Old grammar (retired).** A manifest declared WHERE to look and WHAT to\ncollect in two separate places: a singular `scope:` (or a `scopes:`\nlist) for the subtrees, and one manifest-global `types:` list shared by\nevery scope. The only per-scope discrimination was the `levels` axis\n(organization / folder / project / unknown): a type declared anywhere\nwas swept in every scope whose levels intersected its own.\n\n**New grammar (current).** `scopes:` is the only form, and **every scope\ncarries its own `types:` list**. There is no top-level `types:` and no\nsingular `scope:`. What a scope collects is exactly what is written on\nit.\n\nThe motivation is per-scope type declarations — different resource\ntypes under different subtrees (org governance at org/folder level, the\nconnectivity stack under the networking folder, project IAM only in\nnamed workload projects), which the global list could not express\nwithout waiving every out-of-domain asset by hand. Making the new form\nthe ONLY form, rather than an option next to the old one, makes three\nwhole classes of silent-denominator failure unrepresentable instead of\nmerely validated: there is no inheritance to half-apply, no global list\nto go stale behind the scopes, and no mixed manifest whose meaning\ndepends on which scope the reviewer happened to read.\n\nThe tool refuses the old grammar rather than silently accepting half of\nit, because a half-read manifest would change what the denominator\nmeans — the exact failure the gates exist to prevent.\n\n## Migration: singular `scope:`\n\nWrap the scope as a one-element `scopes:` list and move the `types:`\nlist inside it (indent by four spaces). Nothing else changes — same\ntypes, same levels, same include/exclude, same collected denominator.\n\n```yaml\n# OLD (refused)                     # NEW\nscope:                              scopes:\n  root: organizations/123            - root: organizations/123\n  include:                             include:\n    - folders/456                        - folders/456\ntypes:                                 types:\n  - type: org-policy                     - type: org-policy\n    levels: [organization]                 levels: [organization]\n  - type: iam                            - type: iam\n    levels: [organization, folder]         levels: [organization, folder]\n```\n\n## Migration: `scopes:` list with a shared top-level `types:`\n\nCopy the top-level list into EVERY scope, then (optionally, later)\nnarrow each copy. The unnarrowed copy is semantically identical to the\nold manifest: the collect-time intersection of a type's `levels` with\neach scope's `levels` is unchanged, so the same assets enter the\ndenominator.\n\n```yaml\n# OLD (refused)\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\ntypes:\n  - type: iam\n    levels: [organization, folder, project]\n  - type: cloudresourcemanager.googleapis.com/Folder\n    levels: [organization, folder]\n\n# NEW — verbatim copy first, narrow later\nscopes:\n  - name: org-foundation\n    root: organizations/123\n    levels: [organization, folder]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: stage-projects\n    root: organizations/123\n    levels: [project]\n    include: [projects/111111111111]\n    types:\n      - type: iam\n        levels: [organization, folder, project]\n```\n\nNote the second scope: `cloudresourcemanager.googleapis.com/Folder`\nwas NOT copied into it. A per-scope entry whose `levels` cannot\nintersect its scope's `levels` is refused as a dead declaration\n(`[organization, folder] ∩ [project] = ∅`), so a verbatim copy is only\nvalid where the type could actually fire. Under the old grammar that\nentry was silently inactive for the scope; now it is an error — drop\nit from the scopes it cannot fire in. Entries whose `levels` include\n`unknown` are exempt and may be copied everywhere.\n\nIf YAML repetition bothers you, anchors keep shared lists in one\nplace:\n\n```yaml\nscopes:\n  - name: teams\n    root: organizations/123\n    include: [folders/111]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n  - name: rest\n    root: organizations/123\n    exclude: [folders/111]\n    types: *folder-types\n```\n\n## Key mappings, old → new\n\n| Old | New |\n|---|---|\n| `scope:` (singular) | `scopes:` with one entry |\n| top-level `types:` | each scope's own `types:` (copy, then narrow) |\n| top-level `emission:` (manifest-wide default) | `scopes[].emission` on each scope that wants a non-default style; an omitted family falls back to the built-in default (`per-instance`; `additive` for `iam`), not to another scope |\n| `enumerate:` block, declared once | declared in the `types:` list of every scope that needs the sweep — per-scope lists never inherit, so the block is repeated per scope (a different scope may even carry a different block for the same type) |\n| `iam: true` leaf opt-in, declared once | per scope, like everything else on a type entry: a scope that omits it collects the resource without its leaf IAM |\n| type silently inactive in a scope (empty levels intersection) | refused as a dead declaration (drop the entry from that scope; `unknown` entries exempt) |\n\n## What did NOT change\n\n- The type entry schema: `type`, `levels`, `iam`, `enumerate` are\n  unchanged, including all `enumerate:` guard rails.\n- Scope fields: `name`, `root`, `include`, `exclude`, `levels` are\n  unchanged, including numeric-id resolution for include/exclude.\n- Collect-time semantics for a faithful copy: same sweeps, same\n  intersection rule, same deduplicated denominator. A migrated\n  manifest that copies the old list into every (compatible) scope\n  yields the same `assets` — diffing old and new `inventory.json`\n  asset keys is the cheap proof.\n- Gates and waivers: `coverage.py` and `verify_plan.py` are untouched;\n  existing workspaces, coverage maps and waiver ledgers keep working.\n\n## New output you should start reading\n\n- `_meta.scopes[].declared_types` / `zero_yield_types` — per-scope\n  yield tables. The aggregate `_meta.declared_types` cannot show a type\n  that yields zero in one scope while non-zero in another; the\n  per-scope record (and its stderr warning) can. Quote the per-scope\n  tables in the step-5 report.\n- Every inventory entry carries `scopes: [...]` — the scope(s) that\n  collected it, merged and sorted when scopes overlap.\n\n## Migration checklist\n\n1. Rewrite the manifest as above (or regenerate:\n   `manifest_from_state.py` and `manifest_init.py` emit the new\n   grammar directly).\n2. Run `inventory.py collect` — validation is fail-closed and runs\n   before any API call, so a wrong migration is a refused manifest\n   with the reason, never a shrunken denominator.\n3. Diff the new `inventory.json` asset keys against the last\n   pre-migration one. Identical keys = faithful migration. Missing\n   keys = you narrowed while migrating; deliberate narrowing belongs\n   in its own reviewed manifest change, not folded into the migration.\n4. The manifest is human-owned: the migrated file goes through the\n   same Scope Approval gate as any other manifest edit.\n\nWorked references: `examples/import-manifest.org-foundation.yaml`\n(single scope) and `examples/import-manifest.multi-domain.yaml` (four\nscopes, per-scope types). The fail-closed rules live in SKILL.md\nstep 0 and the cookbook's \"Per-scope type declaration\" subsection.\n\n<!-- File: skills/fabric-importer/references/mapping-cookbook.md (Tokens: ~12502 | Category: anthropic-skill) -->\n# Mapping cookbook — live GCP to Cloud Foundation Fabric\n\nReview-hardened knowledge from the live verification rounds summarised in\n[COVERAGE.md](../COVERAGE.md). Every rule here encodes a bug that was\nactually made and caught; provenance markers like \"(verified r9)\" point\nat the round that proved the rule. Read the section for a resource type\nBEFORE mapping it. When in doubt, read the Fabric module source in this\nrepository — never trust memory for module internals.\n\n**This document is deliberately incomplete, and always will be.** It is\nnot a specification of what the skill supports; it is the precipitate of\na method — the accumulated residue of mapping real resources against a\nlive organization and keeping what survived the gates. A type with no\nsection here is not unsupported. It means nobody has run the method on it\nyet.\n\nSo entries are born the same way every time, and that is the only way\nthey should be born: someone maps a live resource by reading the module\nsource, converges both gates, and writes down what cost them a cycle. See\n\"Step 3b — when the cookbook has never seen this type\" in\n[SKILL.md](../SKILL.md) for the loop. **If you map a type that has no\nsection here, adding one is part of your run report** — that is what\nkeeps this file growing rather than the same traps being paid for on\nevery engagement.\n\nWhat belongs in an entry: the CAI asset-type string as live output\nreturns it, the module addresses and `for_each` key formats, the import\nID format, the ForceNew inputs that must mirror live values, and every\ntrap that cost a cycle. Mark surfaces you did not exercise as unverified\n— an honest gap is worth more than an implied guarantee.\n\nContents: canonical module map · factory emission (opt-in) · universal\nrules · workspace layout · scaffolding scripts · import-ID table ·\nper-type rules (organization, folders, projects, service accounts, log\nsinks, logging buckets, VPC networks, root module) · fallback and\naccelerator.\n\n**Where the fielded data lives.** Address patterns, import IDs, CAI\nasset types and verification rounds are held in\n[`address-map.yaml`](./address-map.yaml) and rendered into the\nimport-ID table below; the prose here owns everything else — the traps,\nthe causal reasoning and the capability gaps. Add a new address to the\nYAML, not to the table, and run\n`uv run scripts/address_map.py` to check both.\n\n## Canonical module map (normative)\n\nFabric modules are the product — this table is the default mapping, not\na suggestion. Raw resources require a documented module capability gap\n(see \"Fallback\" at the end).\n\nEmission has two modes — `per-instance` (the default: one module\ninstance per container, inputs as HCL) and `factory` (data-driven YAML\nconsumed by a Fabric factory carrier). The mode is chosen per resource\nfamily in the manifest's `emission:` block (see \"Factory emission\n(opt-in)\" below); families with `—` in the factory column have exactly\none mode and ignore the knob. Some sub-resources are factory-only by\nFabric convention regardless of the knob: org policies, custom roles\nand custom constraints are carried as `data/` YAML inside their owning\n`organization` / `folder` / `project` instance.\n\n| Scope / resource family | Default (`per-instance`) | `factory` emission — carrier | Carries |\n|---|---|---|---|\n| Organization | `modules/organization` (single instance) | — | org IAM + audit configs, org policies (factory), custom roles (factory), org sinks |\n| Folder | `modules/folder` — one instance per folder | `project-factory` — hierarchy `data/`, one YAML dir per folder, path = key | the folder itself, folder IAM, folder org policies (factory), folder sinks |\n| Project | `modules/project` — one instance per project | `project-factory` — one project YAML per project | the project, services, project IAM, project org policies, metadata |\n| Service accounts | `modules/iam-service-account` | `project-factory` — `service_accounts:` block in the owning project's YAML (only when that project is factory-emitted) | SA + its IAM |\n| Log buckets | `modules/logging-bucket` | — | bucket config (sink destinations) |\n| VPC networks | `modules/net-vpc` | subnets only: `net-vpc` `factories_config.subnets_folder` | network, subnets (incl. proxy-only), routes |\n| VPC firewall rules (legacy) | `modules/net-vpc-firewall` | `net-vpc-firewall` `factories_config.rules_folder` | custom rules + the four `default_rules_config` rules |\n| Cloud NAT & Routers | `modules/net-cloudnat` | — | Cloud NAT + its Cloud Router (NAT-only routers) |\n| HA VPN | `modules/net-vpn-ha` | — | HA VPN gateway, external gateway, tunnels, its own Cloud Router, interfaces, BGP peers |\n| Cloud DNS | `modules/dns` | — (no factory: recordsets are the `recordsets` input, keyed `\"<type> <name>\"`) | managed zones, recordsets |\n| DNS Response Policy | `modules/dns-response-policy` | `dns-response-policy` `factories_config.rules` | response policies & rules |\n| NCC | `modules/ncc-spoke-ra` (hub + router-appliance spokes only) | — | hub; linked-VPC/VPN spokes are a capability gap |\n| Firewall policy rules | `modules/net-firewall-policy` inputs | `net-firewall-policy` `factories_config` rule files | ingress/egress rules |\n| Other | nearest Fabric module for the family; check `modules/` in-repo | see `FACTORIES.md` at the repo root — the authoritative factory catalog (every module factory flows through `factories_config`) | — |\n\nInstance keys: pick short stable slugs at first import (record them in\n`coverage-map.yaml`); keys are immutable afterwards. Because the key —\nnot the display name — defines the address, a console rename plans as\nan in-place `name` update, not destroy/create (verified r6).\n\n## Factory emission (opt-in)\n\n**Status: cookbook-level (C) — rules below are derived from module\nsource in this repository, not yet exercised against live imports.**\nEverything in this section meets that standard and no higher; treat\nunmarked claims as unverified.\n\nThe manifest owns the choice, per resource family. `emission` is a\nmap of family → style, where family names are the canonical-map rows\nand styles are family-specific: `per-instance` (default) or `factory`\nfor module-instance families (those without a factory carrier reject\n`factory`), and `additive` (default) or `authoritative` for the `iam`\npseudo-family (see \"Organization and containers: IAM\"). It lives on\nthe scope entry, like everything else in the manifest: the grammar is\nscopes-only, and there is no top-level position.\n\nResolution, per family:\n\n```text\nscopes[].emission.<family>  →  built-in default (per-instance / additive)\n```\n\nOmission means the built-in default — for `emission` only. **Do not\ngeneralise this to `types:`.** Both keys sit on a scope entry, and\nthere the resemblance stops: `emission` is denominator-neutral (it\ndecides how collected assets are written, and a missing family key\nfalls back safely), while `types:` decides what is collected at all —\nevery scope must declare its full list, and an omission is a refused\nmanifest, not a default. A wrong emission choice still faces both\ngates; a wrong type list shrinks the denominator the gates are\nmeasured against.\n\n```yaml\nscopes:\n  - name: org-foundation\n    levels: [organization, folder]\n    # types: … (each scope carries its own list — elided here)\n    emission:\n      folder: factory        # -> project-factory hierarchy data/\n\n  - name: sandbox\n    levels: [folder, project]\n    include: [folders/333333333333]\n    # types: …\n    # no emission: -> built-in defaults (per-instance; iam additive)\n```\n\n### Subtree-granular emission (scope splitting)\n\nEmission granularity follows scope granularity: to manage *some*\nfolders via factory and others per-instance, split the tree into\nscopes along subtree boundaries and give each its own `emission:` —\nscope `include`/`exclude` already speak subtrees (CAI `ancestors`\nmatching), so no new selector machinery is needed. Remember the\ncoupling: every scope carries its own `types:` list, so a scope added\nfor emission reasons declares its list like any other (usually a copy\nof the list it split from):\n\n```yaml\nscopes:\n  - name: teams               # this subtree is factory-managed\n    root: organizations/000000000000\n    include: [folders/111111111111]\n    levels: [folder]\n    types: &folder-types\n      - type: cloudresourcemanager.googleapis.com/Folder\n        levels: [organization, folder]\n    emission:\n      folder: factory\n\n  - name: rest                # everything else stays per-instance\n    root: organizations/000000000000\n    exclude: [folders/111111111111]\n    levels: [folder]\n    types: *folder-types      # same list; YAML anchors avoid drift\n```\n\nRules that make the split sound (from `project-factory` source,\n`folders.tf`):\n\n- **Factory subtrees can be rooted mid-tree.** A level-1 factory\n  folder takes an explicit `parent` in its `.config.yaml`\n  (`parent = coalesce(<config parent>, \"$folder_ids:default\")`),\n  resolvable through `context.folder_ids` — so a factory-managed\n  subtree may sit under a per-instance-managed or unmanaged folder.\n- **One factory instance for the whole workspace.** All\n  factory-emitted folders share a single `project-factory` instance\n  and one `data/` hierarchy, regardless of how many scopes opted in:\n  disjoint subtrees become multiple top-level `data/` directories,\n  each with an explicit `parent`. The factory is workspace-shaped,\n  not scope-shaped — scopes decide what is collected, emission how it\n  is written.\n- **Boundary crossings never use literals.** Downward\n  (per-instance child under a factory folder):\n  `parent = module.<pf>.folder_ids[\"<path>\"]`. Upward (factory\n  subtree under a per-instance folder): `parent:` in the subtree\n  root's `.config.yaml`, fed via the factory's `context.folder_ids`\n  input. Every style boundary is a seam: name it in the run report.\n- **The 4-level cap applies per factory subtree**, since each subtree\n  root is level 1 — splitting deep hierarchies along scope boundaries\n  also relieves the nesting-depth tradeoff.\n\nWhen to opt in: the imported workspace is the intended day-2 operating\nmodel and the family's ongoing management is data-shaped — the\ncanonical case is foundational infrastructure, with the folder tree,\nfolder IAM and org policies maintained as hierarchical YAML. When in\ndoubt, stay per-instance; graduating a family to factory emission later\nis a `moved {}` exercise, per instance, at a time of your choosing.\n\n### Tradeoffs (why per-instance stays the default)\n\n- **Key = data path, blast radius = subtree.** In `project-factory` the\n  hierarchy directory path IS the instance key: renaming or moving a\n  directory re-keys every folder and project below it — a\n  destroy/create plan across the subtree. The same immutability rule as\n  instance keys applies (choose the `data/` layout once, record it in\n  `coverage-map.yaml`), but the coupling is structural, not per-key.\n  D-04 recovery applies unchanged if it ever happens.\n- **Deeper, internal addresses.** Import blocks target factory\n  internals — these are not a stable interface across Fabric refs, so\n  \"read the module source in this repository, do not trust memory\" does\n  double duty here. Re-verify address shapes on every Fabric ref bump.\n- **Nesting depth.** `project-factory` supports at most 4 folder\n  levels (hardcoded `module.folder-1` … `module.folder-4`); the\n  per-instance `modules/folder` chain has no limit. A hierarchy deeper\n  than 4 cannot be factory-emitted.\n- **The reference rule changes carrier.** Inside factory YAML,\n  references are context interpolations (`$folder_ids:<path>`,\n  `$iam_principals:…`), not module outputs. The rule itself is\n  unchanged — no literals for managed resources — only its spelling:\n  - **Level-1 folders** (under the organization or a boundary parent):\n    `parent: \"organizations/<org_id>\"` or `parent: \"$folder_ids:default\"`.\n  - **Nested folders (Level 2+)**: `parent: \"$folder_ids:<parent_path>\"`\n    (e.g. `parent: \"$folder_ids:networking\"` in `networking/production/.config.yaml`).\n    While `project-factory` defaults omitted `parent` on child folders to\n    `\"$folder_ids:${parent_key}\"`, emitting it explicitly makes hierarchy\n    dependencies clear and self-documenting in the YAML data.\n\n### project-factory: addresses and layout (from module source)\n\n- Folders: `module.<pf>.module.folder-<level>[\"<path>\"]` where\n  `<level>` is 1-based directory depth and `<path>` the `data/`-relative\n  directory path. Folder IAM lives on a **separate** paired instance,\n  `module.<pf>.module.folder-<level>-iam[\"<path>\"]` — both must appear\n  in `coverage-map.yaml` for a folder with IAM.\n- Projects: `module.<pf>.module.projects[\"<key>\"]`; service accounts:\n  see `projects-service-accounts.tf` for the key format.\n- Cross-emission references: the factory outputs `folder_ids` /\n  `project_ids` / `iam_principals` keyed by hierarchy path, so mixed\n  mode composes without literals — a per-instance project under a\n  factory folder takes `parent = module.<pf>.folder_ids[\"<path>\"]`.\n  The inverse (factory projects under per-instance folders) goes\n  through the factory's `context.folder_ids` input.\n- Escaping: the factory-YAML rows of the escaping table below apply\n  wholesale; check `templatestring` usage per field with the same\n  `grep -rn templatestring modules/project-factory/` discipline.\n\nOther carriers (`net-vpc` subnet factory, `net-firewall-policy` rule\nfiles, …): same method — read the carrier's `factories_config` source,\nderive address + key formats, and add a subsection here as part of the\nrun report, marking what was exercised. `FACTORIES.md` at the repo root\nis the authoritative catalog of factory carriers.\n\n## Universal rules\n\n### Imports and coverage\n\n- Every emitted resource gets a paired `import` block in the owning\n  instance's `*-import.tf`, and an entry in `tf/coverage-map.yaml`.\n- Deterministic keys: map keys become Terraform addresses; derive them\n  from content, never from list position. For additive IAM use\n  `slug(role)_slug(member)`, with\n  `_sha256(role\\0member\\0title\\0expression)[:8]` appended when\n  conditional; for authoritative conditional bindings use\n  `slug(role)_slug(title)_sha256(role\\0title\\0expression)[:8]`.\n- Run `terraform fmt -recursive` as the last emission step;\n  `terraform fmt -check -recursive` must pass before the gates. Fmt-ed\n  output is the canonical form the determinism and checksum assertions\n  compare (verified plan-invariant, r11).\n\n### Escaping\n\nWhat to escape depends on two things: the carrier (HCL input or factory\nYAML) and whether the module passes the field through\n`templatestring()`. Check with\n`grep -rn templatestring modules/<module>/` — do not guess.\n\n| Field | Carrier | Templated? | Escape `${` as |\n|---|---|---|---|\n| IAM condition expressions (org, folder, project modules) | HCL input | yes | `$$${` |\n| Org-policy values / condition fields / parameters (all levels) | factory YAML | yes | `$${` |\n| Members, titles, filters, descriptions (non-templated inputs) | HCL input | no | `$${` |\n\n`%{` follows the same pattern with `%`. Escape the `${` and `%{`\nsequences only — never blanket-replace `$`: in HCL, `$$` not followed\nby `{` is two literal dollars, so broad replacement corrupts values\n(caught in r7 scaffolding review). Literal `${` round-trip is verified\nend to end (r3, seeded condition title).\n\n### The reference rule\n\nIf a referenced resource is managed in the workspace, refer to it\nthrough its module output — never by literal string:\n`project_id = module.project-<key>.project_id`,\n`parent = module.folder-<key>.id`, `module.sa-<key>.iam_email` for\nmembers where the SA is managed. Literals are only for references that\nleave the managed graph (a billing account, an unimported destination),\nand every such literal is an \"unmanaged reference\" line in the run\nreport. When a referenced resource later graduates into management,\nupgrading the literal to a module output is part of that import's\ncommit (verified r10: sink destinations). Two deliberate exceptions\nstay literal: import block `id`s and `coverage-map.yaml` — they are\njoin artifacts, and determinism and auditability win.\n\n### Scope semantics\n\n- References never widen scope. Managing config that references an\n  unmanaged resource is fine — org sinks converge without their\n  destinations, IAM bindings without their SA principals — as long as\n  convergence does not depend on the referenced side and the report\n  names it. The manifest decides scope.\n- Scopes narrow on two axes: subtrees (`include`/`exclude`) and what\n  is collected inside them (`levels`, and each scope's own `types:`\n  list). Both are Scope-Approval-gate surface: they move the\n  denominator.\n- Manifest `include` / `exclude` match the CAI `ancestors` array,\n  which stores numeric ids. Both spellings work: `projects/123456789`\n  (the project NUMBER) matches directly, and a project ID is resolved\n  to its number by `inventory.py` via `gcloud projects describe` —\n  prefer the number where known, since resolution needs the describe\n  permission. See `examples/import-manifest.org-foundation.yaml` for\n  the include-vs-exclude trap and\n  `examples/import-manifest.multi-domain.yaml` for selecting specific\n  projects per scope.\n\n#### Per-scope type declaration (`scopes[].types`)\n\nEvery scope carries its own `types:` list — there is no other place\nto declare types, so what a scope collects is exactly what is written\non it. The same type may appear in several scopes with different\n`levels`, `iam` or `enumerate`; an `enumerate:` block is swept only\nin the scopes whose list names it.\n\nFail-closed rules (all refuse the manifest before any API call):\n\n- a scope without a `types:` list, or with `types: []`, is refused —\n  an empty list is a scope that collects nothing, and that is the one\n  thing no scope may say quietly;\n- a type whose `levels` cannot intersect its scope's `levels` is\n  refused as a dead declaration (an entry listing `unknown` is\n  exempt): it reads as coverage and produces none;\n- a duplicate `type:` inside one scope's list is refused.\n\nProvenance in `inventory.json`: each `_meta.scopes[]` entry records\nits own `declared_types` yield counts and `zero_yield_types`;\n`_meta.declared_types` is the aggregate; every inventory entry names\nthe scope(s) that collected it (`scopes: [...]`, merged and sorted\nwhen scopes overlap). **Read the per-scope counts, not only the\naggregate** — a type that yields zero in one scope and non-zero in\nanother never appears in the aggregate zero-yield warning. Same\ndiscipline as `_meta.split_parity`: read the record, not the key.\n\n### Serialization traps\n\n- YAML emission: quote YAML 1.1 booleans (`yes/no/on/off`), preserve\n  multi-line strings verbatim (block style, no rstrip), never sort\n  ordered lists.\n- The API omits false booleans in JSON output (`includeChildren` absent\n  means `false`, not `true`).\n- Empty string vs null — **check which of the two shapes you are facing\n  before reaching for a benign rule** (r17):\n\n  1. **Module passes the variable through directly**\n     (`description = var.description`, with a non-empty variable\n     `default`). Setting the input explicitly to `null` round-trips a\n     live empty or unset value cleanly, because Terraform treats an\n     explicit null as \"attribute not present\" and plans no diff.\n     Verified on `modules/bigquery-dataset` (r17): a live dataset with\n     `description = \"\"` planned `\"\" -> \"Terraform managed.\"` until\n     `description = null` was set, after which it imported clean. **This\n     is the preferred fix — a config change, not a rule.**\n  2. **Module wraps the field in `coalesce(var.x, default)`.** Terraform's\n     `coalesce` skips null AND empty strings, so the default always\n     wins and no config value can express \"empty\". `null` does nothing\n     here. This is the only shape where a `when_before`-guarded benign\n     rule is warranted — D-03, verified on org log sinks\n     (`modules/organization/logging.tf` uses\n     `coalesce(each.value.description, \"...\")`).\n\n  Tell them apart with\n  `grep -n '<field>' modules/<module>/*.tf`. Reaching for a rule when\n  shape 1 applies means shipping a benign entry that a one-line config\n  change would have removed. Never an unguarded attribute rule in\n  either case.\n- ForceNew inputs: for every module, check which inputs are ForceNew on\n  the underlying resource and mirror live values exactly BEFORE the\n  first plan. A destroy/create pair on an imported resource is always a\n  mapping error. Known cases: network/subnet `description` (r9), log\n  bucket `parent` format (r10).\n- **Silently dropped module inputs.** Terraform's object type conversion\n  DISCARDS attributes a module's variable type does not declare — with\n  no error and no warning. `terraform validate` returns Success. So a\n  field you believe you have set may never reach the resource, and from\n  the plan alone that looks identical to a provider artifact. Verified\n  against `modules/certificate-authority-service` before #4107: passing\n  `publishing_options` produced a clean validate and the destructive\n  removal diff regardless.\n\n  Diagnosis rule: when a diff persists on an attribute you are certain\n  you have set, read the module's variable TYPE before concluding\n  anything (`grep -A20 'variable \"<name>\"' modules/<module>/variables.tf`).\n  A silently-dropped input is the mechanical tell for a module\n  capability gap, and it is the single easiest way to mistake a gap for\n  benign drift.\n\n## Workspace layout (normative)\n\nOne file per container instance (organization, folder, project) and a\ndata tree that mirrors the hierarchy using instance keys as path\nsegments — never display names, so renames touch nothing and\nreparenting is a cosmetic `git mv`:\n\n```\ntf/\n├── versions.tf  organization.tf  organization-import.tf\n├── folder-<key>.tf / project-<key>.tf     # one per CONTAINER, + -import.tf\n├── coverage-map.yaml\n└── data/\n    ├── organization/{org-policies,custom-roles}/*.yaml\n    └── folders/<key-a>/org-policies/…            # top-level folder\n        └── <key-b>/org-policies/…                # child nested under parent\n            └── projects/<proj-key>/org-policies/…\n```\n\nProject-contained resources live in their project's file. Every module\ninstance whose resource lives inside a project — service accounts,\nbuckets, VPCs, datasets — goes in that project's `project-<key>.tf`\n(import blocks in `project-<key>-import.tf`), not in a file of its own.\nA project file is the complete, reviewable inventory of that project.\nOnly containers get their own files (owner decision after r8).\n\nFile placement is address-neutral (verified r11: plan-invariant).\nRe-homing a module block between files changes no address, no state, no\ncoverage-map entry — so re-layouts are always safe and do not violate\nincremental immutability, which governs addresses and instance keys,\nnever file placement. Do re-layouts as dedicated, mapping-free commits.\n\nEach instance's `factories_config` points at exactly its own subtree.\nModule-input config (IAM, sinks) lives in the instance's `.tf` file;\nonly factory-driven YAML lives under `data/`. The same constraint at\ndifferent levels lands on different paths — no collisions.\n\n## Scaffolding scripts (run-local generators)\n\nWriting a helper script to emit the workspace is legitimate — with\ngovernance:\n\n1. Workspace-confined and org-confidential: scripts embed org data;\n   they live in the workspace (or its private repo), never in the\n   skill. Parameterize by construction — org id and container ids come\n   from the manifest, `inventory.json` or argv, never hardcoded.\n2. The cookbook is the spec; the script is an implementation. A\n   divergence is a defect even while gates are green, because gates\n   only verify paths the live org exercises today (r7 review found four\n   such latent divergences).\n3. Scope comes from the frozen denominator: the set of emitted things\n   joins against `inventory.py` keys. Detail fetches (`describe`,\n   `get-iam-policy`) are fine.\n4. Deterministic: sorted iteration, stable keys, double-run\n   byte-identical, `terraform fmt` as the last step.\n5. No silent promotion: durable knowledge becomes cookbook prose;\n   durable mechanism goes through human review into a tested frozen\n   tool. Never \"the script works, keep it\".\n6. Reported: the run report lists scaffolding used and gate verdicts;\n   commit the script as round evidence (sanitized and parameterized).\n7. Strict address matching: When filtering or mutating import blocks\n   across incremental runs, match full resource type strings (e.g.\n   `google_organization_iam_custom_role`) rather than loose substrings\n   (like `\"custom_role\"`), which collide with role IDs such as\n   `roles/latchkey_custom_role_automation`.\n\n## Import-ID quick table\n\n**Generated — do not edit by hand.** The rows below are rendered from\n[`address-map.yaml`](./address-map.yaml), which owns the fielded subset\nof this cookbook: asset type, module, address pattern, import ID and the\nround that verified it. Edit the YAML and re-render:\n\n```bash\nuv run scripts/address_map.py --render-cookbook\n```\n\nTwo invariants are machine-enforced there and cannot be restated here by\naccident: an address pattern is claimed by exactly one entry, and a CAI\nasset type serviced by more than one module must say how to tell the\ncarriers apart. `<placeholder>` segments are chosen per engagement.\nA blank `Verified` cell means the surface was never exercised live —\nan honest gap, not an implied guarantee.\n\n<!-- BEGIN ADDRESS-MAP (generated) -->\n\n| Resource | Module | Address pattern | Import ID | Verified |\n|---|---|---|---|---|\n| BigQuery Dataset | `modules/bigquery-dataset` | `module.<instance>.google_bigquery_dataset.default` | `projects/<project_id>/datasets/<dataset_id>` | r17 |\n| CAS CA Pool | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool.default[0]` | `projects/<project_id>/locations/<location>/caPools/<name>` | r14 |\n| CAS CA Pool IAM | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role>` | — |\n| CAS CA Pool IAM (cond.) | `modules/certificate-authority-service` | `module.<instance>.google_privateca_ca_pool_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/locations/<location>/caPools/<name> <role> <condition_title>` | — |\n| Certificate Authority | `modules/certificate-authority-service` | `module.<instance>.google_privateca_certificate_authority.default[\"<id>\"]` | `projects/<project_id>/locations/<location>/caPools/<pool>/certificateAuthorities/<id>` | — |\n| DNS zone | `modules/dns` | `module.<instance>.google_dns_managed_zone.dns_managed_zone[0]` | `projects/<project_id>/managedZones/<zone>` | r12 |\n| DNS recordset | `modules/dns` | `module.<instance>.google_dns_record_set.dns_record_set[\"<type> <name>\"]` | `projects/<project_id>/managedZones/<zone>/rrsets/<record_name>/<type>` | — |\n| DNS Response Policy | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy.default[0]` | `projects/<project_id>/responsePolicies/<response_policy_name>` | — |\n| DNS Response Policy rule | `modules/dns-response-policy` | `module.<instance>.google_dns_response_policy_rule.default[\"<rule_name>\"]` | `projects/<project_id>/responsePolicies/<response_policy_name>/rules/<rule_name>` | — |\n| Folder | `modules/folder` | `module.folder-<key>.google_folder.folder[0]` | `folders/<id>` | r6 |\n| Folder IAM | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.authoritative[\"<role>\"]` | `folders/<id> <role>` | r6 |\n| Folder IAM (cond.) | `modules/folder` | `module.folder-<key>.google_folder_iam_binding.bindings[\"<key>\"]` | `folders/<id> <role> <condition_title>` | r6 |\n| Folder IAM member (additive, default) | `modules/folder` | `module.folder-<key>.google_folder_iam_member.bindings[\"<key>\"]` | `folders/<id> <role> <member>` | — |\n| Folder sink | `modules/folder` | `module.folder-<key>.google_logging_folder_sink.sink[\"<name>\"]` | `folders/<id>/sinks/<name>` | r6 |\n| Folder org policy | `modules/folder` | `module.folder-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `folders/<id>/policies/<constraint>` | r6 |\n| GCS bucket | `modules/gcs` | `module.<instance>.google_storage_bucket.bucket[0]` | `<project_id>/<bucket_name>` | r12 |\n| Service account | `modules/iam-service-account` | `module.sa-<key>.google_service_account.service_account[0]` | `projects/<project_id>/serviceAccounts/<email>` | r8 |\n| SA IAM binding | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.authoritative[\"<role>\"]` | `projects/<project_id>/serviceAccounts/<email> <role>` | r8 |\n| SA IAM binding (cond.) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_binding.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <condition_title>` | r8 |\n| SA IAM member (additive, default) | `modules/iam-service-account` | `module.sa-<key>.google_service_account_iam_member.bindings[\"<key>\"]` | `projects/<project_id>/serviceAccounts/<email> <role> <member>` | — |\n| KMS CryptoKey | `modules/kms` | `module.<instance>.google_kms_crypto_key.default[\"<key>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key>` | r13 |\n| KMS CryptoKey IAM | `modules/kms` | `module.<instance>.google_kms_crypto_key_iam_binding.authoritative[\"<key>.<role>\"]` | `projects/<project_id>/locations/<location>/keyRings/<name>/cryptoKeys/<key> <role>` | r13 |\n| KMS KeyRing | `modules/kms` | `module.<instance>.google_kms_key_ring.default[0]` | `projects/<project_id>/locations/<location>/keyRings/<name>` | r13 |\n| Log bucket | `modules/logging-bucket` | `module.logging_bucket_<key>.google_logging_project_bucket_config.bucket[0]` | `projects/<project_id>/locations/<location>/buckets/<name>` | r10 |\n| NCC Hub | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_hub.hub[0]` | `projects/<project_id>/locations/global/hubs/<hub_name>` | — |\n| NCC spoke (router appliance) | `modules/ncc-spoke-ra` | `module.<instance>.google_network_connectivity_spoke.spoke_ra` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | — |\n| Compute address (global PSA) | `modules/net-address` | `module.<instance>.google_compute_global_address.psa[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (global PSC) | `modules/net-address` | `module.<instance>.google_compute_global_address.psc[\"<key>\"]` | `projects/<project_id>/global/addresses/<name>` | r19 |\n| Compute address (regional internal) | `modules/net-address` | `module.<instance>.google_compute_address.internal[\"<key>\"]` | `projects/<project_id>/regions/<region>/addresses/<name>` | r19 |\n| Cloud NAT | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router_nat.nat` | `projects/<project_id>/regions/<region>/routers/<router>/<nat>` | r12 |\n| Cloud Router | `modules/net-cloudnat` | `module.<nat_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | r12 |\n| Hierarchical Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_firewall_policy.hierarchical[0]` | `locations/global/firewallPolicies/<id>` | r7 |\n| Global Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_network_firewall_policy.net_global[0]` | `projects/<project_id>/global/firewallPolicies/<name>` | r7 |\n| Regional Network Firewall Policy | `modules/net-firewall-policy` | `module.<instance>.google_compute_region_network_firewall_policy.net_regional[0]` | `projects/<project_id>/regions/<region>/firewallPolicies/<name>` | r7 |\n| Interconnect Attachment | `modules/net-vlan-attachment` | `module.<instance>.google_compute_interconnect_attachment.default[\"<key>\"]` | `projects/<project_id>/regions/<region>/interconnectAttachments/<name>` | — |\n| VPC network | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_network.network[0]` | `projects/<project_id>/global/networks/<name>` | r3 |\n| VPC route (gateway) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.gateway[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ilb) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ilb[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (instance) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.instance[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (ip) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.ip[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC route (vpn tunnel) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_route.vpn_tunnel[\"<key>\"]` | `projects/<project_id>/global/routes/<net>-<key>` | r9 |\n| VPC subnet | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.subnetwork[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r3 |\n| VPC subnet (private NAT) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.private_nat[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | — |\n| VPC subnet (proxy-only) | `modules/net-vpc` | `module.net_vpc_<key>.google_compute_subnetwork.proxy_only[\"<region>/<name>\"]` | `projects/<project_id>/regions/<region>/subnetworks/<name>` | r9 |\n| VPC firewall rule (custom) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.custom_rules[\"<rule_name>\"]` | `projects/<project_id>/global/firewalls/<rule_name>` | — |\n| VPC firewall rule (default, admins) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_admins[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-admins` | — |\n| VPC firewall rule (default, http) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_http[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-http` | — |\n| VPC firewall rule (default, https) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_https[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-https` | — |\n| VPC firewall rule (default, ssh) | `modules/net-vpc-firewall` | `module.<instance>.google_compute_firewall.allow_tag_ssh[0]` | `projects/<project_id>/global/firewalls/<network_name>-ingress-tag-ssh` | — |\n| External VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_external_vpn_gateway.external_gateway[\"<peer_key>\"]` | `projects/<project_id>/global/externalVpnGateways/<name>` | — |\n| HA VPN Gateway | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_ha_vpn_gateway.ha_gateway[0]` | `projects/<project_id>/regions/<region>/vpnGateways/<name>` | — |\n| Cloud Router (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router.router[0]` | `projects/<project_id>/regions/<region>/routers/<name>` | — |\n| Cloud Router interface (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_interface.router_interface[\"<tunnel_key>\"]` | `<project_id>/<region>/<router_name>/<interface_name>` | — |\n| Cloud Router BGP peer (VPN) | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_router_peer.bgp_peer[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/routers/<router_name>/<peer_name>` | — |\n| VPN Tunnel | `modules/net-vpn-ha` | `module.<vpn_instance>.google_compute_vpn_tunnel.tunnels[\"<tunnel_key>\"]` | `projects/<project_id>/regions/<region>/vpnTunnels/<name>` | — |\n| Org audit config | `modules/organization` | `module.organization.google_organization_iam_audit_config.default[\"<service>\"]` | `<org> <service>` | r3 |\n| Custom role | `modules/organization` | `module.organization.google_organization_iam_custom_role.roles[\"<id>\"]` | `organizations/<org>/roles/<id>` | r3 |\n| Org IAM binding (authoritative opt-in) | `modules/organization` | `module.organization.google_organization_iam_binding.authoritative[\"<role>\"]` | `<org> <role>` | r3 |\n| Org IAM binding (cond.) | `modules/organization` | `module.organization.google_organization_iam_binding.bindings[\"<key>\"]` | `<org> <role> <condition_title>` | r3 |\n| Org IAM member (additive, default) | `modules/organization` | `module.organization.google_organization_iam_member.bindings[\"<key>\"]` | `<org> <role> <member>` | — |\n| Org log sink | `modules/organization` | `module.organization.google_logging_organization_sink.sink[\"<name>\"]` | `organizations/<org>/sinks/<name>` | r3 |\n| Org policy | `modules/organization` | `module.organization.google_org_policy_policy.default[\"<constraint>\"]` | `organizations/<org>/policies/<constraint>` | r5 |\n| Tag Key | `modules/organization` | `module.organization.google_tags_tag_key.default[\"<short_name>\"]` | `tagKeys/<id>` | r18 |\n| Tag Value | `modules/organization` | `module.organization.google_tags_tag_value.default[\"<key_short_name>/<value_short_name>\"]` | `tagValues/<id>` | r18 |\n| Tag Binding | `modules/organization` | `module.<instance>.google_tags_tag_binding.binding[\"<key>\"]` | `tagBindings/<url-encoded-parent-and-value>` | r18 |\n| PAM entitlement | `modules/project` | `module.<container>.google_privileged_access_manager_entitlement.default[\"<entitlement_id>\"]` | `<parent>/locations/<location>/entitlements/<entitlement_id>` | — |\n| Project | `modules/project` | `module.project-<key>.google_project.project[0]` | `projects/<project_id>` | r4 |\n| Project IAM | `modules/project` | `module.project-<key>.google_project_iam_binding.authoritative[\"<role>\"]` | `<project_id> <role>` | r4 |\n| Project IAM (cond.) | `modules/project` | `module.project-<key>.google_project_iam_binding.bindings[\"<key>\"]` | `<project_id> <role> <condition_title>` | r4 |\n| Project IAM member (additive, default) | `modules/project` | `module.project-<key>.google_project_iam_member.bindings[\"<key>\"]` | `<project_id> <role> <member>` | — |\n| Project org policy | `modules/project` | `module.project-<key>.google_org_policy_policy.default[\"<constraint>\"]` | `projects/<project_id>/policies/<constraint>` | — |\n| Project service | `modules/project` | `module.project-<key>.google_project_service.project_services[\"<api>\"]` | `<project_id>/<api>` | r4 |\n| Project service (orgpolicy) | `modules/project` | `module.project-<key>.google_project_service.org_policy_service[0]` | `<project_id>/orgpolicy.googleapis.com` | r4 |\n| Workload Identity Pool | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool.default[\"<pool_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>` | r18 |\n| Workload Identity Pool Provider | `modules/project` | `module.project-<key>.google_iam_workload_identity_pool_provider.default[\"<pool_id>/<provider_id>\"]` | `projects/<project_id>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>` | r18 |\n| Pub/Sub Topic | `modules/pubsub` | `module.<instance>.google_pubsub_topic.default` | `projects/<project_id>/topics/<topic_name>` | r17 |\n| VPC-SC Access Level | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_level.basic[\"<key>\"]` | `accessPolicies/<policy_id>/accessLevels/<level_name>` | r15 |\n| VPC-SC Access Policy | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_access_policy.default[0]` | `<policy_id>` | r15 |\n| VPC-SC Service Perimeter | `modules/vpc-sc` | `module.<instance>.google_access_context_manager_service_perimeter.regular[\"<key>\"]` | `accessPolicies/<policy_id>/servicePerimeters/<perimeter_name>` | r15 |\n| NCC spoke (raw — capability gap) | raw | `google_network_connectivity_spoke.<instance>` | `projects/<project_id>/locations/<location>/spokes/<spoke_name>` | r12 |\n\n<!-- END ADDRESS-MAP -->\n\nVerified across r2–r19 (see COVERAGE.md for the per-family evidence).\nFor anything else: check the provider documentation's import section, or\nemit the import block with the CAI asset name minus the\n`//<service>.googleapis.com/` prefix — verified to match the provider\nimport ID byte-for-byte on every family tested (r5 oracle, 100%) — and\nlet plan tell you: it errors loudly and safely on a wrong ID.\n\n## Per-type rules\n\n### Organization: org policies\n\n- Factory files in `data/.../org-policies/` wrap each policy in a map\n  keyed by the constraint name (`<constraint>: {rules: [...]}`) — the\n  module merges raw YAML via `yamldecode()` (verified r5).\n- `dryRunSpec` is real config: emit it under a `dry_run:` block in the\n  same constraint definition. A dry-run-only policy must never become\n  `rules: []` (verified r4; CAI omits dry-run-only policies — the\n  inventory service-API sweep exists for exactly this).\n- `parameters` must be a JSON string (the module feeds it to\n  `templatestring()`), serialized deterministically\n  (`json.dumps(..., sort_keys=True)`) (verified r3).\n- Factory `fileset()` is non-recursive: filenames are\n  `<constraint>.yaml`, never subdirectories.\n- Value lists are ordered attributes: preserve live order (verified r3).\n- **Verbatim condition expressions**: Condition expressions in org policy\n  rules returned by the API can carry a trailing newline (`\\n`). In factory\n  YAML, use block scalar style (`|`) rather than quoted/stripped strings so\n  the trailing newline is preserved and does not plan as an in-place update.\n\n### Organization: custom roles\n\n- Factory configuration:\n  `factories_config = { custom_roles = \"<path>\" }`.\n- Factory YAML schema: `includedPermissions: [...]` (matching the IAM\n  API attribute), `title`, `description`, `stage` (verified r5).\n- Title/description/stage need a Fabric ref containing PR #4102\n  (released in `v58.0.0`); on `v57.0.0` and earlier they plan as\n  in-place updates (D-01).\n\n### Organization and containers: IAM\n\n- **Additive is the default emission for IAM** (manifest\n  `emission.iam`, styles `additive` | `authoritative`, same two\n  positions and resolution as every other emission family). Each live\n  (role, member, condition) tuple maps to one `iam_bindings_additive`\n  entry → one `google_*_iam_member.bindings[\"<key>\"]`. Why default:\n  an apply can only create or destroy the exact tuples emitted — it\n  can never remove members it does not manage — and it is the\n  non-authoritative posture Google's PAM documentation recommends.\n  The honest tradeoff: additive emission cannot converge out-of-band\n  *additions* away — unmanaged members accumulate invisibly to the\n  plan, while coverage still counts the container policy as mapped.\n  Estates that want IAM fully declarative as the day-2 model opt into\n  `emission.iam: authoritative` (`iam` / `iam_bindings` maps — the\n  emission every existing verified round exercised); the choice is\n  the human's, recorded in the manifest.\n- Assert IAM policy `version: 3` when conditions exist — v1/v2\n  responses flatten conditions, which is silent privilege escalation.\n- `deleted:` principals cannot be imported; exclude them from bindings\n  but always surface them in the report (under additive emission the\n  tombstones simply stay unmanaged; under authoritative emission an\n  apply would remove them).\n- **PAM grant bindings are runtime state, never configuration —\n  excluded from the denominator by `inventory.py`, not by you.** On\n  grant activation, Privileged Access Manager injects a temporary\n  time-bound conditional binding into the container's allow policy and\n  revokes it itself when the grant ends; Google's PAM documentation\n  explicitly warns against managing these bindings in Terraform.\n  Whenever IAM is collected, `inventory.py` also sweeps active grants\n  through CAI (`privilegedaccessmanager.googleapis.com/Grant` — CAI\n  models the Grant, not the Entitlement; one extra call per scope,\n  covered by the same `cloudasset.viewer` grant) and strips matching\n  bindings before normalization. Matching is deterministic — (target,\n  role, requester) come from the grant resource itself, never from\n  string-matching the binding's condition, whose format Google does\n  not publish — and deliberately narrow: conditional bindings only, so\n  a permanent binding coinciding with a grant is kept. A container\n  whose policy holds only grant bindings mints no `#iam` entry:\n  structural exemption, not a waiver. Stripped bindings are stamped in\n  `_meta.pam_grant_exclusions`; quote them in the run report's\n  machine-managed exclusions. At emission time, work from the stamped\n  list — if a live policy shows a conditional binding that looks\n  PAM-shaped but is not in the stamp, the inventory is stale:\n  re-collect, do not classify by hand. Exclusion is also\n  collision-safe: under the additive default nothing unmanaged can be\n  removed by construction, and even under authoritative opt-in Fabric\n  `iam` maps produce bindings authoritative for the (role,\n  no-condition) tuple while PAM bindings always carry a condition, so\n  an apply of the emitted config cannot revoke an active grant. Declaring the Grant type in a manifest is refused. Do not\n  confuse grants with PAM *entitlements*, which are ordinary importable\n  configuration mapping to the `pam_entitlements` factory on\n  `organization` / `folder` / `project`.\n- Audit configs map to `logging_data_access` on `modules/organization`\n  (verified r3).\n- Conditional binding import IDs need a non-empty condition title;\n  format is space-separated:\n  `<org_or_folder_or_project_id> <role> <condition_title>`.\n- **Multi-module state consolidation**: When inferring from Terraform\n  state where IAM for the same scope/role is split across multiple module\n  instances (e.g. `module.organization`, `module.organization-iam`, or\n  dynamic factory loops): under authoritative emission, member lists\n  must be union-merged across all matching state bindings before\n  mapping into canonical module `iam` maps; under additive emission,\n  deduplicate (role, member, condition) tuples across instances\n  instead — each tuple is emitted exactly once.\n- **Verbatim condition expressions & ForceNew**: IAM condition expressions\n  from live API policies frequently contain a trailing newline (`\\n`).\n  Because `condition.expression` on `google_*_iam_binding` is `ForceNew`,\n  stripping whitespace or omitting the newline triggers a spurious\n  destroy/create (replacement) plan. Preserve the exact string from the live\n  API (e.g. via HCL heredoc `<<-EOT` or explicit `\\n`).\n\n### Folders (`modules/folder`, one instance per folder)\n\n- Chain parents by module reference\n  (`parent = module.folder-<parent-key>.id`; top-level folders point at\n  the org). No nesting-depth limit — unlike factory emission, which\n  caps at 4 levels (see \"Factory emission (opt-in)\").\n- Set `deletion_protection = true` (imported provider default; avoids a\n  spurious in-place change — verified r2).\n- Folder-level org policies, sinks and IAM live on the same instance as\n  the folder — never a separate wrapper.\n- Display-name renames are plain `name` updates (verified r6).\n- Re-keying recovery (D-04) — only if instance keys themselves must\n  change, which should be never. If a plan ever shows a destroy/create\n  pair on the same folder id:\n  1. Stop — never let that plan stand, even unapplied.\n  2. Rename the factory path/key to the new slug (content unchanged).\n  3. Add `moved { from = <old address> to = <new address> }` at the\n     workspace root and keep the block permanently.\n  4. Update the `coverage-map.yaml` entry's address in the same commit\n     (the one sanctioned exception to address immutability — the key\n     stays, the address moves with the `moved` block as evidence).\n  5. Re-plan: the pair must collapse to no-op or update before anything\n     else proceeds. Automatic `moved {}` generation is deliberately\n     deferred until a real engagement needs it at scale.\n\n### Projects (`modules/project`, one instance per project)\n\n- Chain parent by module reference or the org id for top-level\n  projects.\n- Set `deletion_policy = \"PREVENT\"`. `auto_create_network = true`\n  matched live state with zero diff on the pilot projects — confirm per\n  project via plan rather than assuming (r7).\n- `services`: the enabled-service list from the live ServiceUsage API.\n  `orgpolicy.googleapis.com` maps internally to\n  `google_project_service.org_policy_service[0]`; every other service\n  maps to `google_project_service.project_services[\"<api>\"]` (r7).\n- CAI can report ghost services (disabled in ServiceUsage but present\n  in CAI): cross-check `gcloud services list` and waive with evidence\n  (r7).\n- `service_agents_config`: set `{ create_primary_agents = false,\n  grant_default_roles = false, grant_service_agent_editor = false }` on\n  imported projects to avoid unwanted resource creation or default role\n  injection (r7).\n- `labels`: pass `{}` or live labels. Provider-computed\n  `terraform_labels` diffs are covered by a `when_before`-guarded\n  benign rule (r7).\n\n### Service accounts (`modules/iam-service-account`) — verified r8\n\n- Manifest: `iam.googleapis.com/ServiceAccount` with `iam: true` —\n  without the opt-in, impersonation grants (IAM on the SA) are\n  invisible to the denominator.\n- Google-managed and service-agent SAs (`*-compute@developer.`,\n  `*@cloudservices.`, `service-*@gcp-sa-*`) must be waived, never\n  imported — they are lifecycle-bound to services, not user config.\n- SA keys: do not import or enumerate user-managed keys (secrets);\n  waive and note in the report.\n- Reference rule: `project_id = module.project-<key>.project_id`;\n  impersonation members that are themselves managed SAs use\n  `module.sa-<key>.iam_email`; unmanaged principals stay literal and\n  are listed in the report.\n\n### Log sinks (org/folder level)\n\n- Never import `_Default` / `_Required` (Google-managed; automatically excluded from the denominator by `inventory.py`).\n- The module `type` enum is `gcs` (not `storage`), `bigquery`,\n  `logging`, `pubsub`, `project` — map the destination API prefix\n  accordingly; fail on an unknown prefix, never rewrite (r7).\n- `destin\n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: skills/fabric-importer/references/operating-contract.md (Tokens: ~1771 | Category: anthropic-skill) -->\n# fabric-importer — operating contract\n\nRules for any agent or human operating this skill.\n\n## 1. Trust boundary\n\nThe skill is intentionally lean: the model performs discovery-to-Terraform\nmapping itself, and correctness comes from un-gameable verification, not\nfrom deterministic codegen. That only holds if the boundary is respected:\n\n| Layer | Contents | Who may change it |\n|---|---|---|\n| Frozen scripts | everything in `scripts/`, rulesets included (`benign-drift.yaml`) | Humans, via code review only. The model may run them, never edit them. |\n| Human-owned run files | `import-manifest.yaml`, `waivers.yaml` | Humans. The model drafts/proposes; a human commits. |\n| Model workspace | `tf/`, `coverage-map.yaml`, reports, worklists | The model — with immutable-address discipline (§4). |\n\nA converged verdict is only meaningful if the gates and their rulesets\nwere not touched by the same actor whose output they verify.\n\nBoth gates stamp runtime provenance into their output, covering every\nverification script. To check a recorded verdict, run `uv run\nscripts/integrity.py` from a clean checkout of the same commit and\ncompare; `--verbose` identifies which file differs. This is evidence,\nnot prevention — an actor editing a gate could edit the stamp — but it\nremoves the silent case, and any stored gate output can be checked after\nthe fact.\n\nThe script provenance alone does not prove which *inputs* a gate judged,\nso both gates also stamp every input they read — resolved path and\nSHA256 — on the line after the digest: the plan JSON (or stdin), the\nrules file actually loaded, the inventory, the coverage map, the waiver\nledger, and the set of `*.tf` files scanned. A non-default `--rules`\nfile additionally prints a loud warning and downgrades a converged\nverdict to exit code 3, so a substituted ruleset can never produce a\npassing gate — otherwise\npointing the gate at a permissive file would be a cheaper route to green\nthan editing the frozen file this contract forbids editing. A recorded verdict is therefore attributable to\nexact inputs, not just to a tool build.\n\nThe gates also fail closed on degenerate inputs: a plan without\n`format_version` and `resource_changes` (state JSON, `{}`), a plan with\nzero resource changes without `--allow-empty-plan`, and an empty\ninventory without `--allow-empty-inventory` all exit non-zero. An empty\nverdict is never evidence of convergence.\n\n**Which decisions need a human.** The pattern behind this table is that\nevery human-owned artifact either narrows what the gates check or is\nirreversible. Scope narrows the denominator — on two axes, since each\nscope carries its own `types:` list, and the tool refuses the shapes\nwhere that narrowing would be invisible (a scope without a list, an\nempty list, a declaration whose levels cannot match its scope), because\neach of those would shrink the denominator while still exiting 0. A\nwaiver removes an entry from the denominator; a benign rule removes an\nentry from the residual set; an apply\ncannot be undone. Everything else — module choice, file layout, instance\nkeys, raw-vs-module calls — is falsifiable by the plan and can be\nmodel-owned. Use that test when something new appears: *does this shrink\nwhat is checked, or is it irreversible?* If yes, a human decides.\n\nThe manifest is human-owned for that reason, and it is why a manifest is\nthe only place an operator may declare a native (non-CAI) enumerator —\nin the `types:` list of each scope that needs it, swept only in that\nscope, since per-scope lists never inherit.\nCloud Asset Inventory does not model every GCP resource, and a type it\ncannot see is invisible to both gates at once, so such types are\nenumerated with a read-only `gcloud` command and merged into the same\ndenominator (`references/cai-blind-spots.md`). `inventory.py` ships a\nfrozen table of these for types known to be absent from the catalogue\nand applies them automatically — that is tool-owned, reviewed code, and\na model may no more edit it than any other frozen ruleset. A manifest\nblock covers a type the table does not, or overrides one. That command\nwidens what is checked rather than narrowing it, but it is still a\nhand-written piece of the denominator: `inventory.py` refuses non-read-only verbs, refuses\n`--filter`/`--limit` and friends, refuses a key template that is not\nunique, and stamps every sweep it ran into `inventory.json`'s\n`_meta.native_sweeps`. A model may draft the block; a human commits it.\n\nWaivers therefore carry an optional `signed_by` field. It is reported by\ndefault and enforced with `coverage.py --require-signed-waivers`.\nEnforcement is opt-in on purpose: demanding attribution where no human is\npresent produces invented signatures, and a forged name defeats the audit\nit was meant to support.\n\n## 2. Absolute invariants\n\n1. **NEVER run `terraform apply`** — against any environment, for any\n   reason. The generated authoritative IAM bindings make an apply\n   equivalent to overwriting live org IAM with a stale snapshot.\n2. **Read-only against GCP APIs.** Allowed: `list`, `describe`,\n   `get-iam-policy`, `search`, asset inventory reads, plus\n   `terraform init/validate/plan/show`. Never\n   `set/add/remove/update/create/delete`. Prefer a dedicated read-only\n   identity so the rule is enforced by IAM, not prose.\n3. **Never synthesize Terraform state.** Plan JSON assertions\n   (`verify_plan.py`) are the only accepted convergence evidence.\n4. **Org-confidential output.** Workspace contents and anything derived\n   from a real org (IDs, domains, principals, resource names, counts)\n   never enter a PUBLIC repository, an upstream pull request, or a\n   shareable document unsanitized. Fabric is used fork-and-own, so\n   during the OWN phase these artifacts legitimately live in the\n   operator's own private fork alongside the code — that is the expected\n   working directory, and the boundary to police is publication, not the\n   filesystem. Pulled `.tfstate` and saved `*.tfplan` are the exception\n   in kind, not degree: they contain secret VALUES (Secret Manager\n   payloads, generated keys) and are deleted once the manifest is\n   drafted and the plan verified.\n5. **Never rationalize residual diffs.** A diff believed benign becomes a\n   *proposed* `benign-drift.yaml` entry with evidence, reviewed by a\n   human. Until accepted, the run is red.\n\n## 3. Convergence definition\n\nA run is converged when, for every manifest-declared scope:\n\n1. `coverage.py` exits 0 — every in-scope inventory key is mapped to\n   emitted import blocks or human-waived; and\n2. `verify_plan.py` exits 0 — every planned change is a clean import,\n   no-op, or matches a reviewed benign-drift rule.\n\nBoth are required. The manifest bounds the claim, scope by scope:\nconvergence says nothing about types or levels a scope did not declare,\nand each scope declares its own list — so the run report must state the\nbound per scope, not as a single sentence about \"the manifest\".\n\n## 4. Incremental discipline\n\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable; re-runs extend the workspace for worklist entries only.\n- Genuine re-keying (renames, refactors) requires `moved {}` blocks and a\n  prominent note in the run report.\n- Manifest changes only ever widen or narrow scope; they never license\n  rewriting existing mappings.\n\n## 5. Toolchain\n\nTerraform >= 1.5; google + google-beta providers `>= 7.40, < 8`; Fabric\nmodule refs pinned to released tags (surface unreleased-feature\ntrade-offs to the user instead of silently pinning SHAs). Provider or\nFabric ref bumps invalidate `benign-drift.yaml` verification stamps and\nthe cookbook's address table — re-verify before trusting a verdict.\n\n<!-- File: skills/fabric-importer/scripts/benign-drift.yaml (Tokens: ~1208 | Category: anthropic-skill) -->\n# Copyright 2026 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# HUMAN-OWNED benign-drift ruleset for verify_plan.py.\n#\n# Every entry declares a class of plan diffs that is a provider/schema\n# artifact rather than real config-vs-reality drift. Entries require:\n#   - resource:        terraform resource type (\"*\" for any)\n#   - attributes:      top-level attribute paths covered\n#   - reason:          human-reviewed justification\n#   - verified_against: provider (and optionally terraform) version the\n#     justification was checked on. Re-verify on provider major bumps.\n#   - a bound on the DIFF (machine-enforced; an unbounded rule is\n#     ignored with a warning when it would otherwise have applied):\n#       when_after: {attr: value}          exact destination value\n#       when_after_matches: {attr: regex}  destination matches, one line\n#       when_after_computed: [attr, ...]   destination is FULLY unknown at\n#         or true                          plan time (provider-computed)\n#       when_unchanged: [attr, ...]        these attributes must not\n#                                          appear in the diff at all\n#     Every attribute in `attributes` needs a direct destination guard,\n#     OR the rule declares `when_unchanged` naming the live-affecting\n#     siblings that make the diff inert (the `terraform_labels` case:\n#     provider bookkeeping is harmless exactly while `labels` and\n#     `effective_labels` are untouched). `when_unchanged` must be\n#     disjoint from `attributes`.\n#     `when_before` alone is NOT sufficient. A rule that bounds only the\n#     live value accepts an arbitrary destination, so the gate would\n#     report CONVERGED for a plan that really writes to the resource on\n#     apply — the exact silent-pass this ruleset exists to prevent.\n#   Only the diff bound is machine-enforced; `reason` and\n#   `verified_against` are conventions a human reviewer upholds.\n#\n# Keep `attributes` as narrow as possible: changed paths are reported as\n# TOP-LEVEL keys, so listing a block attribute waives every future change\n# anywhere under it.\n#\n# The model operating the skill MUST NOT edit this file. verify_plan.py\n# prints a proposal template for unmatched updates; a human reviews the\n# diff and commits the entry.\n\nrules:\n  - resource: google_folder\n    attributes: [timeouts]\n    when_before:\n      timeouts: {}\n    when_after:\n      timeouts: null\n    reason: >-\n      Google provider v7 emits `timeouts {} -> null` in the import preview\n      for imported folders; the attribute is not a live setting and\n      disappears once state is persisted.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_logging_organization_sink\n    attributes: [description]\n    # Guard: only sinks whose LIVE description is the empty string are\n    # covered; a real description drift on a sink must stay residual.\n    when_before:\n      description: \"\"\n    # Guard: the destination must be the module default, which\n    # modules/organization/logging.tf builds as\n    # `coalesce(each.value.description, \"${each.key} (Terraform-managed).\")`.\n    # Any other new description is real drift and stays residual.\n    when_after_matches:\n      description: '[^\\n]{1,96} \\(Terraform-managed\\)\\.'\n    reason: >-\n      Live GCP log sinks with empty description (\"\") cannot round-trip through\n      Fabric's coalesce(v.description, default) which treats empty string as null\n      and applies the default description.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: google_compute_subnetwork\n    attributes: [send_secondary_ip_range_if_empty]\n    when_before:\n      send_secondary_ip_range_if_empty: null\n    when_after:\n      send_secondary_ip_range_if_empty: true\n    reason: >-\n      Fabric modules/net-vpc hardcodes send_secondary_ip_range_if_empty = true\n      on google_compute_subnetwork to ensure secondary range removals propagate,\n      which causes an in-place preview diff (null -> true) on imported subnets.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n  - resource: \"*\"\n    attributes: [terraform_labels]\n    when_before:\n      terraform_labels: {}\n    # Guard: terraform_labels is provider bookkeeping, harmless exactly\n    # while the attributes that really carry labels to the API are\n    # untouched. If `labels` or `effective_labels` move too, this is a\n    # real label write and stays residual.\n    when_unchanged: [labels, effective_labels]\n    reason: >-\n      Google provider v7 computes `terraform_labels: {} -> {...}` during import\n      preview when default/inherited provider labels exist while\n      effective_labels and labels match live state with zero underlying mutation.\n    verified_against:\n      provider: 7.45.0\n      status: verified\n\n<!-- File: skills/fabric-importer/SKILL.md (Tokens: ~6085 | Category: anthropic-skill) -->\n---\nname: fabric-importer\ndescription: Reverse-engineers live GCP resources into Cloud Foundation Fabric Terraform modules with native import blocks, driven by an agreed import manifest and verified by automated completeness and plan-convergence gates. Use when migrating or importing existing GCP infrastructure into Cloud Foundation Fabric or Terraform.\n---\n\n# Fabric Importer — agent protocol\n\nYou (the agent) reverse-engineer live GCP resources into Cloud Foundation\nFabric Terraform. There is no per-resource pipeline: **you perform the\nmapping yourself**, guided by the cookbook, and two frozen gates verify\nyour output. Trust comes from the gates, not from you.\n\n## Trust boundary (non-negotiable)\n\n- **Frozen scripts** — everything in `scripts/`, rulesets included\n  (`benign-drift.yaml`). You may RUN them; you must NEVER modify them.\n  The helpers are frozen for the same reason as the gates: they decide\n  the SCOPE the denominator is built from, so editing one shrinks what\n  \"complete\" means. Both gates print runtime provenance and active input hashes;\n  a reviewer compares them against a clean checkout. If a gate is\n  genuinely broken, report it with evidence — do not patch around it.\n- **Human-owned files**: the import manifest and the waiver ledger. You\n  may draft or propose changes; a human reviews and commits them.\n- **Yours**: everything in the workspace (`tf/`, `coverage-map.yaml`,\n  reports). Constraint: once an address exists, it is immutable — extend,\n  never rewrite (see \"Incremental runs\").\n\n## Safety contract\n\n1. **NEVER run `terraform apply`.** The workspace contains authoritative\n   surfaces (org policies, sinks — and authoritative IAM where opted\n   into), so an apply is equivalent to overwriting live config with a\n   snapshot. The additive-IAM default narrows that blast radius; it does\n   not lift this rule. Verification is plan-only via `verify_plan.py`.\n   Never synthesize state files.\n2. **Read-only against GCP**: `list`/`describe`/`get-iam-policy`/asset\n   export only. Prefer a dedicated read-only identity\n   (`roles/cloudasset.viewer` + per-service viewers, via impersonation)\n   so an accidental apply fails with 403.\n3. **All workspace output is org-confidential.** Fabric is used\n   fork-and-own, so during the OWN phase these artifacts legitimately\n   live in the operator's own private fork alongside the code — that is\n   the expected working directory. What must never happen is any of it\n   reaching a PUBLIC repository, an upstream pull request, or a document\n   that leaves the engagement: no org/folder/project ids, principals,\n   resource names or asset counts. Pulled `.tfstate` and saved\n   `*.tfplan` additionally contain secret VALUES (Secret Manager\n   payloads, generated keys); delete them once the manifest is drafted\n   and the plan is verified, and never commit them anywhere.\n4. **Never rationalize a residual diff.** If you believe a plan diff is a\n   provider artifact, propose a `benign-drift.yaml` entry with evidence in\n   your report and stop. A human accepts it or the run stays red.\n\n## Human-in-the-Loop Gates\n\nFour decisions are human-owned and BLOCKING: scope approval (the\nmanifest), waiver signing (`signed_by` in `waivers.yaml`), benign-drift\nacceptance (`scripts/benign-drift.yaml`), and the final apply sign-off.\nEach is called out at its step below; the README's gate table is the\noperator-facing contract. Keep mechanical, reversible steps (asset\ncollection, scaffolding, linting, plan checks) autonomous. If running\nnon-interactively and confirmation cannot be obtained, stop — never\nassume approval.\n\n## Operational Workflow\n\n### Step 0 — establish the manifest (with the user)\n\nThe manifest is the user's contract: which resource types, at which\nlevels, under which subtrees — declared per scope, since `scopes:` is\na list and **every scope carries its own `types:` list**. Never invent\nit silently. If one exists, confirm it; otherwise run one of the\ndrafting workflows:\n\n**Option A — Inferred from existing Terraform state(s)** (preferred when migrating existing TF):\n\n```bash\nuv run scripts/manifest_from_state.py --state <state-files...> --out import-manifest.yaml\n# add --force to replace an existing manifest, or --out - to review first\n```\n\nSee [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md).\n\n**Option B — Surveyed from live GCP estate**:\n\n```bash\nuv run scripts/inventory.py survey --scope organizations/ORG_ID --out survey.json\nuv run scripts/manifest_init.py --survey survey.json \\\n  --scope organizations/ORG_ID --out import-manifest.yaml\n```\n\nThen interview the user over the draft (every discovered type is listed\nwith per-level counts, commented out; foundation types are pre-enabled):\n\n- What do you want under Terraform management *now*? (Start narrow —\n  org foundation first, workloads later. Show them\n  `examples/import-manifest.org-foundation.yaml` as the reference.)\n- At which levels? (e.g. org IAM yes, project IAM no.)\n- Same depth everywhere, or different depths per subtree? (Different ⇒\n  several scopes, each with its own `types:` list —\n  `examples/import-manifest.multi-domain.yaml` is the reference.)\n- Any subtrees to exclude (sandboxes, decommissioning trees)?\n\nWhen writing a scope's `include`/`exclude`, note that CAI `ancestors`\nstore NUMERIC project numbers; `inventory.py` automatically resolves\nalphanumeric project IDs to numbers during enumeration, but project numbers\nremain supported and canonical.\n\n**Per-scope `types:` rules (fail-closed, exit 1 before any API call).**\nA scope's list is the only place types are declared, so what a scope\ncollects is exactly what is written on it. The same type may appear in\nseveral scopes with different `levels`, `iam` or `enumerate`. The\nvalidator refuses: a scope without a `types:` list or with `types: []`\n(a scope that collects nothing may not say so quietly); a type whose\n`levels` cannot intersect its scope's `levels` (dead declaration —\nentries listing `unknown` are exempt); a duplicate `type:` within one\nlist; and the retired top-level `scope:`/`types:` grammar, which is\nrejected with migration instructions — a manifest from an earlier\nengagement is migrated per\n[references/manifest-migration.md](./references/manifest-migration.md).\nUnlike `types:`, a scope's\n`emission:` map inherits built-in defaults for omitted families — it\nis denominator-neutral; do not reason from one knob to the other.\n\nThe user commits the manifest. Scope changes later = manifest edit +\nincremental re-run, never a rewrite.\n\n### Step 1 — enumerate (the denominator)\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml --out inventory.json\n```\n\nBy default, `inventory.py survey` and `inventory.py collect` filter out\nsoft-deleted containers (`DELETE_REQUESTED` / `DELETE_IN_PROGRESS` folders and\nprojects) and their child resources, matching what is active in the GCP Console.\nPass `--include-deleted` if you explicitly need to capture soft-deleted assets in\nthe denominator (e.g. to audit or waive them).\n\nSimilarly, resources GCP creates or manages on the operator's behalf —\nun-creatable auto-generated VPC routes (subnet-local routes, NCC and peering routes),\nGoogle-managed default log sinks and log buckets (`_Default` and `_Required`),\nand ephemeral Privileged Access Manager (PAM) grants (`privilegedaccessmanager.googleapis.com/Grant`) —\nare automatically excluded from the denominator. Pass `--include-auto-generated`\n(or `--include-auto-generated=family,...` e.g. `routes`, `logging-defaults`, `pam-grants`;\n`--include-logging-defaults` and `--include-pam-grants` remain supported as aliases)\nif you explicitly need to capture them in the denominator.\n\n**CAI is the default source of the denominator, never the boundary of\nit.** Cloud Asset Inventory does not model every GCP resource. A type it\ndoes not support must be enumerated by other means and merged into the\nsame denominator — never dropped: an unenumerated asset is invisible to\nBOTH gates at once, which is the exact failure the gates exist to\nprevent. `inventory.py` handles the known cases itself: built-in gcloud\nenumerators for types absent from the CAI catalogue\n(`NATIVE_ENUMERATORS`), automatic sibling sweeps for the Compute\nfamilies whose list surface splits them into separate scoped types\n(stamped in `_meta.split_type_sweeps`, per entry as `cai_list_type`),\nand a hard stop — separated from permission failures — where no\nenumerator exists. When a sweep stops, or a declared type yields less\nthan you expected, read\n[references/cai-blind-spots.md](./references/cai-blind-spots.md) BEFORE\nworking around it: it holds the full remedy ladder (wrong type string →\nsplit CAI taxonomy → native `enumerate:` block, declared in the `types:`\nlist of each scope that needs it, since per-scope lists never inherit →\nREST API), the guard rails for enumerator blocks, and the built-in\ntable. The manifest is human-owned: you draft an enumerator block, a\nhuman commits it; one that worked belongs in your report as a proposed\naddition to the built-in table.\n\nThe split-type table is a frozen snapshot of a doc that changes, so\ncheck it against live CAI at least once per engagement:\n\n```bash\nuv run scripts/inventory.py collect --manifest import-manifest.yaml \\\n  --out inventory.json --verify-search-parity\n```\n\nOne extra call per scope; fatal if the search surface returns an asset\nthe list sweep did not. `_meta.split_parity` EMPTY means the probe did\nnot run; a probe that ran and found nothing is a record whose\n`only_in_search` is empty. Read the record, not the key — and say which\nin the report.\n\nEvery entry that did not come from CAI is stamped into\n`inventory.json`'s `_meta.native_sweeps` with the verbatim command;\nthose entries, and `_meta.split_type_sweeps`, belong in the step-5\nreport. Collection closes with a one-line cost summary (`N gcloud\ncall(s) in Xs: …`) and records every command it ran in\n`_meta.api_calls`; add `--verbose` after the subcommand to watch each\ncall as it happens, and quote the summary line in the report — on a\nlarge estate the per-container org-policy sweep dominates the run, and\nthis is what makes that visible instead of guessed at.\n\n### Step 2 — get your worklist\n\n```bash\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --worklist-out worklist.yaml\n```\n\nFirst run: everything is missing. Re-runs: only the delta.\n\n### Step 3 — map and emit (your job)\n\n**Fabric modules are the product, not an optimization.** Every resource\nmaps to its canonical Fabric module (the cookbook's \"Canonical module\nmap\" is normative): `modules/organization` for everything org-level,\nand — by default — one `modules/folder` instance per folder and one\n`modules/project` instance per project, each carrying its own IAM, org\npolicies, and sinks. For families where Fabric also offers a factory\ncarrier (e.g. `project-factory` for the folder hierarchy,\n`net-firewall-policy` rule factories), the manifest may opt into\n`factory` emission per resource family via `emission:` — a human call,\nmade when the imported workspace is meant to become the day-2\noperating model (e.g. foundational folders + org policies + IAM as\nYAML data). The default stays per-instance: shallow, chosen addresses\nwith no coupling to factory internals. The cookbook's \"Factory\nemission (opt-in)\" section is normative for how, and for the\ntradeoffs.\n\nFor each worklist entry, emit module config (factory YAML or module\ninputs, per the cookbook) + a paired import block targeting the\nmodule-internal address, maintaining `tf/coverage-map.yaml`\n(inventory key → list of Terraform addresses). Read\n`references/mapping-cookbook.md` FIRST: it encodes the hard-won rules\n(escaping, import-ID formats, coalesce traps, dry-run keys, hashed\ncondition keys).\n\n**IAM is emitted additive by default.** Bindings map to\n`iam_bindings_additive` (one `google_*_iam_member` per role/member/\ncondition tuple): an apply can only create or destroy the exact pairs\nemitted and can never strip members it does not manage — the posture\nGoogle itself recommends for PAM coexistence. Authoritative emission\n(`iam` / `iam_bindings`) is a deliberate opt-in via `emission.iam:\nauthoritative` in the manifest, for estates that want IAM fully\ndeclarative as the day-2 model; the cookbook's container-IAM rules\ncover both and the tradeoff.\n\n**Machine-managed IAM is excluded, never imported — and never part of\nthe denominator.** `inventory.py` strips Privileged Access Manager\ngrant bindings deterministically before the denominator is formed —\nnothing to map, nothing to waive — and stamps them in\n`_meta.pam_grant_exclusions`; report them in step 5, like `deleted:`\nprincipal tombstones (which remain a mapping-time exclusion). PAM\n*entitlements* are ordinary importable configuration; only *grants*\nare excluded. The cookbook's container-IAM rules are normative,\nincluding the strip mechanics.\n\nWhen the cookbook has no section for a type — which is the normal case,\nnot an error — follow the method below. The cookbook is the precipitate\nof that method, not a precondition for it.\n\n**Raw `google_*` resources are the exception, never a shortcut.**\nPermitted only when (a) the canonical module cannot express a required\nattribute of the live resource (a *module capability gap*), or (b) no\nFabric module covers the type at all. Every raw resource MUST appear in\nthe step-5 report's \"module capability gaps\" section with the concrete\nreason. For drafting raw config,\n`terraform plan -generate-config-out=generated.tf` is provider-faithful\nby construction — but it is an inspection technique, not a mapping\ntier.\n\nRead the Fabric module source **in this repository** before targeting\nmodule-internal addresses; do not trust memory. Root-module wiring\n(`versions.tf`, provider config, module blocks) is part of your output.\n\nThree cookbook sections are normative for HOW you emit: **Workspace\nlayout** (one file per CONTAINER; project-contained resources live in\ntheir project's file; hierarchical `data/` keyed by instance keys),\n**The reference rule** (managed resources are referenced via module\noutputs, never literals), and **Scaffolding scripts** (run-local\ngenerators are fine — parameterized, cookbook-conformant, deterministic,\nnever promoted into the skill).\n\n### Step 3b — when the cookbook has never seen this type\n\nThe cookbook will never be comprehensive, and it is not meant to be. It\nis the accumulated residue of running this loop against real resources;\nevery entry in it was produced this way. Meeting an undocumented type is\nroutine — apply the same method, at the same standard of evidence.\n\nWhat makes that safe is division of labour. **The gates make the method\nsafe**: you cannot take a wrong mapping to green, because a bad import\nID errors, a mismatched attribute stays residual, and an unmapped asset\nfails coverage. **The cookbook makes it cheap and correctly diagnosed**:\nit is the difference between converging on the first attempt and the\nthird, and — more importantly — between identifying a diff's cause and\nconfidently misreading it. The gates cannot catch a misdiagnosis, because\nclassifying a diff as benign is a human judgement they delegate. Do not\ntreat an absent cookbook section as licence to reason loosely.\n\nThe loop:\n\n1. **Census the type.** Confirm the CAI asset-type string against live\n   `gcloud asset list` output — type strings from module knowledge have\n   been wrong before, and a non-zero yield is NOT proof the type is\n   complete (split families answer happily with part of the family).\n   Record which enumeration path had to be used. Where the type has any\n   global/regional/zonal distinction, census BOTH surfaces and\n   reconcile the counts before trusting either; where CAI does not\n   model the type at all, enumerate it another way and merge it into\n   the same denominator (step 1, `references/cai-blind-spots.md`) —\n   never narrow the manifest around it.\n2. **Find the module and read its source.** Canonical module map first,\n   then `modules/` in this repository. Read the resource blocks for\n   addresses and `for_each` key formats, and the variables file for the\n   input shape. Never work from memory about module internals.\n3. **Mirror ForceNew inputs to live values BEFORE the first plan.**\n   Check which inputs are ForceNew on the underlying resource and set\n   them to exactly what is live. A destroy/create pair on an imported\n   resource is always a mapping error, never an acceptable outcome.\n4. **Emit, plan, and read the result.** The plan is the oracle. A wrong\n   import ID fails loudly and safely; a wrong attribute shows up as a\n   diff. This step is cheap — use it rather than deliberating.\n5. **Diagnose every diff before classifying it**, in this order:\n   - Read the module's variable TYPE. Terraform silently discards\n     attributes a variable type does not declare — no error, `validate`\n     returns Success — so a field you believe you set may never have\n     reached the resource.\n   - Check whether the field is assigned directly or wrapped in\n     `coalesce()`. Directly assigned fields accept `null` and need no\n     rule; `coalesce` fields cannot express \"empty\" at all.\n   - Check the escaping matrix if the value passes through\n     `templatestring()`.\n   Only after those does the question \"is this benign?\" become\n   answerable.\n6. **Land on one of three outcomes — all legitimate:** clean\n   convergence; a *module capability gap* (the module cannot express an\n   attribute that changes behaviour — emit a raw resource mirroring\n   live values, name the module and the missing attribute in the\n   report's capability-gaps section; a successful outcome and\n   upstream-Fabric-issue material); or *no module covers the type*\n   (same, plus whether a module is worth proposing upstream). A\n   residual you cannot explain is none of these: report it and stop.\n7. **Write the cookbook entry as part of your report.** A type you\n   mapped and verified is knowledge the next engagement should not have\n   to rediscover: the CAI type string, the module addresses and\n   `for_each` key formats, the import ID, the ForceNew inputs, and every\n   trap that cost you a cycle. Mark surfaces you did not exercise as\n   unverified. This step is what makes the tool compound instead of\n   resetting to zero on every engagement — without it, the same traps\n   are paid for repeatedly.\n\n### Step 4 — run the gates\n\n**The two gates measure different things, and neither can catch the\nother's failure.** Gate 1 compares the denominator against the TEXT of\nyour workspace — the `import {}` blocks it parses out of `*.tf`, the\ncoverage map, the waivers. It never runs Terraform and never reads your\ncloud. Gate 2 compares the plan against reality.\n\nSo gate 1 passes happily on code that is completely wrong, and gate 2\npasses happily on a workspace covering three of fifty-nine assets: a\nplan of three clean imports is perfectly converged, because Terraform\nonly knows about what you wrote down. **A converged plan is not evidence\nof completeness**, and a reconciled coverage report is not evidence of\ncorrectness. Never report one as if it covered the other, and never\nskip a gate because the other one is green.\n\n**An orphan import block is evidence about the DENOMINATOR, not just\nabout the coverage map.** When gate 1 reports an `import {}` block\ntargeting a resource that is not in `inventory.json`, and you know that\nresource is live, the first hypothesis is that enumeration missed it —\nnot that the mapping is spurious. This is the only signal that fires for\na silently short denominator, and it has been mistaken for a coverage-map\nproblem and waived away in a live run. Investigate the enumeration path\nfor that type (step 1, and `references/cai-blind-spots.md`) BEFORE\nproposing a waiver: a waiver over a short denominator makes the gap\npermanent and puts a human's name on it.\n\n```bash\nterraform -chdir=tf fmt -recursive   # generated code is ALWAYS fmt-ed\nuv run scripts/coverage.py --inventory inventory.json --workspace tf \\\n  --waivers waivers.yaml --require-signed-waivers\nterraform -chdir=tf init -input=false\nrm -f tf/verify.tfplan   # never let a stale plan answer for a failed one\nterraform -chdir=tf plan -input=false -out=verify.tfplan\nterraform -chdir=tf show -json verify.tfplan | uv run scripts/verify_plan.py\n```\n\nDo not add `-detailed-exitcode` here: it returns **2 whenever the plan is\nnon-empty**, and a converged import plan is always non-empty (every\n`import {}` block is a planned change). Under `set -e`, or to an agent\ntreating non-zero as failure, it aborts the workflow at exactly the step\nthat is meant to validate it. `verify_plan.py` is the verdict, not\nterraform's exit code.\n\nThe `rm -f` matters: `plan` and `show` are separate commands, so if the\nplan step fails (expired credentials, a provider error, a syntax error in\nnewly emitted HCL) `show -json` will happily read the PREVIOUS\niteration's plan file and the gate can print CONVERGED for code that was\nnever planned.\n\n`verify_plan.py` exit codes: `0` converged, `1` malformed input, `2`\nresidual changes, `3` advisory run (a substituted `--rules` file — never\na passing gate).\n\n`--require-signed-waivers` is the default posture: every waiver must\ncarry a `signed_by` recording the human who accepted it. Drop the flag\nonly in a fully unattended run, and say so in the report.\n\nIterate step 3 until **both** gates are green. Convergence =\ncoverage reconciled ∧ every plan change is a clean import, no-op, or\nreviewed-benign.\n\n### Step 5 — report\n\nProduce a run report for the user: what was imported (counts by type,\nwith the Fabric module used for each), an **enumeration sources**\nsection naming every type that did not come from CAI and the exact\ncommand that produced it (`_meta.native_sweeps` covers declared\nenumerators; quote anything you enumerated out of band yourself), a\n**module capability gaps** section — every raw resource emitted, which\nmodule fell short, which attribute, and whether it is\nupstream-Fabric-issue material — plus\nbenign diffs relied on, waivers in effect, proposed waivers/benign rules\nawaiting human review, and anything the manifest excludes that you\nbelieve deserves attention. State whether `--verify-search-parity` was\nrun and what it found. An empty `_meta.split_parity` means the probe did\nnot run; a clean probe is a record whose `only_in_search` is empty.\nReporting an unchecked table as clean is exactly the kind of claim this\nsection exists to prevent. Quote the **per-scope** yield tables\n(`_meta.scopes[].declared_types` / `zero_yield_types`), not only the\naggregate `_meta.declared_types`: a type can be non-zero org-wide while\nthe one scope that declared it yielded nothing, and only the per-scope\nrecord shows that. Convergence claims are bounded per scope — state\neach scope's declared surface, not one sentence about \"the manifest\".\nPlain facts; no \"100%\" claims beyond what the\ngates literally verified.\n\nThe report MUST include the verbatim stdout of both final gate runs —\nincluding the provenance stamp and every `input … sha256:` line.\nThose lines are what lets a reviewer tie the verdict to the exact tool\nbuild and the exact inputs; a report that paraphrases the gates instead\nof quoting them is not evidence.\n\n## Incremental runs\n\n- The workspace persists. `coverage.py`'s worklist is the only to-do\n  list; touch nothing that is already mapped.\n- Commit `.terraform.lock.hcl` in the (user-owned, private) workspace\n  repo: convergence verdicts are only reproducible against the exact\n  provider build that produced them.\n- Existing Terraform addresses and `coverage-map.yaml` entries are\n  immutable. If a mapping must genuinely change (e.g. folder renamed),\n  emit `moved {}` blocks and flag it prominently in the report.\n- Manifest NARROWING fails closed: `coverage.py` flags now-out-of-scope\n  mapped keys as `stale?` problems and exits 2. Resolution is a human\n  decision: re-widen the manifest, sign waivers, or deliberately retire\n  the mappings (remove config + import blocks + coverage-map entries in\n  one reviewed commit) — never silently.\n- Manifest widening only ever adds worklist entries; it never invalidates\n  existing mappings.\n\n## Prerequisites\n\n`terraform` >= 1.5, `gcloud`, and [`uv`](https://docs.astral.sh/uv/).\n\nRun every script with `uv run scripts/<name>.py`. Each one declares its\nown dependencies inline (PEP 723), so nothing needs installing or\nactivating and the run cannot pick up a stale system PyYAML. Where `uv`\nis genuinely unavailable, `python3` >= 3.10 with PyYAML installed works\nand the arguments are identical — say which you used in the report.\n\nDiscovery IAM — grant the read-only role set from the README's \"Minimal\nRead-Only IAM Permissions\" table on the scope root. It covers org\nfoundation, folders, projects, networking, and storage; families\nverified later (KMS, CAS, VPC-SC, BigQuery, Pub/Sub, Tags, WIF) may\nneed additional per-service viewer roles — the plan gate names the\nmissing permission when one is needed.\n\n## References\n\n- [README.md](./README.md) — human user guide and architecture overview\n- [COVERAGE.md](./COVERAGE.md) — resource maturity matrix\n- [references/mapping-cookbook.md](./references/mapping-cookbook.md) —\n  Fabric mapping rules, escaping, import-ID table, known traps\n- [references/address-map.yaml](./references/address-map.yaml) — the\n  fielded subset of the cookbook (asset type, module, address, import\n  ID, verification round); validated and rendered by\n  `scripts/address_map.py`, never read by the gates\n- [references/inferring-manifests-from-state.md](./references/inferring-manifests-from-state.md)\n  — inferring import manifests from existing Terraform states\n- [references/manifest-migration.md](./references/manifest-migration.md)\n  — the manifest grammar changed to scopes-only with per-scope\n  `types:` lists; how to migrate a manifest written for the retired\n  grammar, and what stayed identical\n- [references/cai-blind-spots.md](./references/cai-blind-spots.md) —\n  where the CAI denominator is incomplete and what to do about it\n- [references/operating-contract.md](./references/operating-contract.md)\n  — invariants and trust boundaries in full\n\n<!-- File: skills/fast/networking/references/1-requirements-to-topology.md (Tokens: ~6004 | Category: anthropic-skill) -->\n# Phase 1 & 2 Reference: Requirements Discovery & Unified Topology/IPAM Proposal\n\nThis reference governs **Phase 1 (Requirements Discovery)** and **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Rather than asking the user to pick a FAST dataset name upfront—or dumping unrelated questions in static blocks—guide them through a **Conditional Decision DAG** and then present a **single unified Topology + Pre-Filled IPAM Proposal** for confirmation before generating YAML.\n\n---\n\n## 1. Silent Entry Detection (Never Ask a Workflow Meta-Question)\n\nInspect the user's first message and any attached files silently—**never** ask the user a multiple-choice question about how they want to start (interview vs. diagram vs. clone):\n\n- **If the user starts with a general design request (no diagram or detailed spec attached):** Start **Turn 1** of the Conditional Interview DAG (Section 2) immediately. You may include a brief one-line note in prose (*\"If you have an architecture diagram, IP table, or existing dataset you'd like to adapt, feel free to share it at any point\"*), then ask the Turn 1 baseline questions.\n- **If the user attaches or points to an architecture diagram (`.png`, `.jpg`, `.svg`, `.pdf`, `.drawio`) or an IP/subnet table / detailed prompt:**\n  1. Extract all stated facts (Projects, Single VPC vs. Hub & Spokes, regions, subnet CIDRs, interconnect/VPN links, NVAs, NCC hubs, peerings, DNS zones) and infer the required Stage 2 plumbing (e.g., GKE secondary ranges, `/23` `proxy_only: true` subnets for ALBs, `psa_configs` for Cloud SQL/AlloyDB, `$security_profile_groups:` for Cloud NGFW Enterprise).\n  2. Mark **Phase 1** completed and jump straight to **Phase 2 (Unified Topology, IPAM & Plumbing Proposal)** (Section 4), pre-filling sensible defaults for any unspecified CIDRs/ASNs and highlighting only the remaining ambiguities for confirmation.\n- **If the user asks to clone & mutate an existing in-tree dataset:** Jump straight to **Phase 2 (Section 4)** showing the proposed delta and IPAM table against `fast/stages/2-networking/datasets/<name>/`.\n- **If the user only asks to validate an existing dataset on disk:** Run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the results without running the interview.\n\n---\n\n## 2. Phase 1: Conditional Interview DAG (Step-by-Step Discovery)\n\nFollow this conditional flow so you never ask moot questions (such as asking about hubs and spokes when the user selected a Single Shared VPC):\n\n```text\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork                     │\n  │ (Ask together ONLY if not already stated in the user's initial prompt)     │\n  │  1. Stage 0 Integration (`0-org-setup` outputs vs. Standalone `.tfvars`)   │\n  │  2. Concrete Target Region(s) (`1..N` specific regions + shortname keys)   │\n  │  3. VPC Footprint Fork:                                                    │\n  │     • Path A: Single Shared VPC (1 VPC, microsegmented via FW/Tags/NGFW)   │\n  │     • Path B: Multi-VPC Hub & Spokes (Separate VPCs for IAM / quota scale) │\n  └───────────────────┬────────────────────────────────────┬───────────────────┘\n                      │                                    │\n         [Path A: Single Shared VPC]          [Path B: Multi-VPC Hub & Spokes]\n         (SKIP all hub/spoke/NCC/peering       ▼\n          and transit-NVA questions!)     ┌────────────────────────────────────┐\n                      │                   │ Turn 2B (Step 2a): Multi-VPC Scope │\n                      │                   │  1. Which spoke environments?      │\n                      │                   │  2. Inter-VPC reachability &       │\n                      │                   │     transit inspection model?      │\n                      │                   └────────────────┬───────────────────┘\n                      │                                    │\n                      └─────────────────┬──────────────────┘\n                                        ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Turn 2A (if Path A) / Turn 3B (if Path B) (Step 2b):                       │\n  │ Connectivity, Security & Workload Plumbing (tailored to Path A or Path B)  │\n  │  1. Hybrid Connectivity (None / HA VPN / Cloud Interconnect / Both)        │\n  │  2. Internet Egress (Cloud NAT / Air-gapped [or Centralized via NVA])      │\n  │  3. Firewall Posture (Cloud-Native L4 Policies vs. Cloud NGFW Enterprise)  │\n  │  4. Workload Plumbing (None / GKE / L7 ALB `proxy_only` / PSA / PSC / etc) │\n  └────────────────────────────────────┬───────────────────────────────────────┘\n                                       ▼\n  ┌────────────────────────────────────────────────────────────────────────────┐\n  │ Phase 2 (Step 3 — Single Unified Sign-Off Gate):                           │\n  │ Present Topology + Pre-Filled Regional IPAM/ASN/DNS Defaults + Output Path │\n  │ (User confirms defaults or tweaks CIDRs/names -> Proceed to Phase 3 Gen)   │\n  └────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Turn 1 (Step 1): Baseline & Primary VPC Footprint Fork\nAsk only the following mutually independent questions (skipping any already answered in the user's prompt):\n\n1. **FAST `0-org-setup` Integration vs. Standalone Stage Execution:** Are you running `2-networking` on top of an existing FAST `0-org-setup` deployment, or standalone?\n   - *Why this matters:* `2-networking` can create new projects or reuse existing projects in either mode. Knowing if `0-org-setup` is used is strictly for **tfvars, provider, and context plumbing** (whether `0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` provide `$folder_ids`, `$storage_buckets`, `billing_account`, and `prefix`, or whether those variables must be supplied directly in `2-networking.auto.tfvars`).\n2. **Target Regions (`1..N`) & Location Keys:** Which specific GCP region(s) (`1` to `N`) will host your resources?\n   - *Important:* Present **concrete** single-region and multi-region choices (e.g., `europe-west1 + europe-west8`, `europe-west8 + europe-west12`, `europe-west8 (single region)`, `europe-west1 (single region)`, `us-central1 + us-east4`, or custom free-text) so you never need a second question to ask *which* region they picked.\n   - *Note on `defaults.yaml` location keys:* `primary` and `secondary` in the reference datasets are just arbitrary key names in `context.locations`. A dataset can define `1` to `N` regions using either role names (`primary`, `secondary`) or concise region shortnames (`ew1`, `ew8`, `ew12`, `usc1`). Propose clean shortname keys matching their chosen regions.\n3. **Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`):**\n   - *Framing to include in the question:* In GCP, network microsegmentation and L7 IPS (via Hierarchical/Network Firewall Policies, IAM-governed Secure Tags, and Cloud NGFW Enterprise) work **inside a single Shared VPC**—you do not need multiple VPCs for network security isolation. Multiple VPCs are used to **isolate IAM/administrative blast radii** across host projects (e.g., `dev` vs. `prod`) and to **scale horizontally** across VPC quota domains.\n   - **Option A — Single Shared VPC:** A single Shared VPC (`main-0` / `shared-0`) hosting all environments/subnets, segmented via subnet IAM, Firewall Policies, Secure Tags, and optional Cloud NGFW Enterprise. *(Ideal for PoCs, simple/compact estates, or low-latency enclaves—no hubs, spokes, NCC, or peering required).*\n   - **Option B — Multi-VPC Hub & Spokes:** Separate Shared VPCs per environment (`dev`, `prod`, etc.) interconnected through a central hub (via NCC, 3rd-party NVAs, or Peering).\n\n> **STOP after Turn 1 and wait for the user's answer before asking Turn 2 questions.**\n\n---\n\n### Turn 2 — Path A (`Single Shared VPC` Selected): Single-VPC Plumbing Discovery\nWhen the user selects **Option A (Single Shared VPC)** in Turn 1:\n- **Do NOT ask about spoke environments, spoke-to-spoke routing, NCC hubs, VPC peering, or inter-VPC transit NVAs.**\n- Ask only the Single-VPC questions below:\n  1. **Environment Subnets within the Single VPC:** Do you want a single general-purpose subnet per region, or separate subnets per logical environment (e.g., `dev` and `prod` subnets within the same Shared VPC for subnet-level IAM delegation)?\n  2. **Hybrid Connectivity:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)** terminating in this VPC? *(If Cloud Interconnect is selected, note that Phase 2 will confirm Dedicated vs. Partner mode, peer ASN, and interconnect/VLAN attachment parameters).*\n  3. **Internet Egress:** Should the VPC egress via **Cloud NAT** (`nat_config`), or have **no internet egress** (Air-gapped / Private Google Access only)?\n  4. **Firewall & Inspection Posture:** Are **Cloud-Native L4 Firewalls** (Hierarchical + VPC Firewall Policies) sufficient, or do you also want **Cloud NGFW Enterprise** (L7 IPS / TLS inspection rules via `$security_profile_groups`—or intra-VPC 3rd-party NVA hairpinning via Policy-Based Routing)?\n  5. **Workload & Managed Service Plumbing:** Which platforms will run in this VPC so Stage 2 can pre-provision their subnets/ranges? *(Include an explicit **\"None (standard VM subnets only)\"** option alongside **GKE Clusters** (`pods`/`services` secondary ranges), **Application Load Balancers** (`/23` `proxy_only: true` subnet), **Private Services Access / PSA** (`psa_configs` for Cloud SQL/AlloyDB/Memorystore/NetApp), **Private Service Connect / PSC** (`psc: true` NAT subnets or `psc:` consumer endpoints in `addresses.yaml`), and **Serverless / Vertex AI** (`/24` Direct VPC Egress / `network_attachments`))*.\n\n---\n\n### Turn 2 — Path B (`Multi-VPC Hub & Spokes` Selected): Inter-VPC & Inspection Discovery\nWhen the user selects **Option B (Multi-VPC Hub & Spokes)** in Turn 1, ask:\n\n1. **Spoke Environments:** Which workload environments need dedicated spoke Shared VPCs? *(Default: `dev` and `prod`; common additions: `nonprod`/`uat`, `dmz`)*.\n2. **Inter-VPC Reachability & Transit Inspection Pattern:** How should your spoke VPCs communicate with each other and with shared services/hybrid links?\n   - **Option A — Direct Spoke-to-Spoke over NCC (`hub-and-spokes-ncc` with `MESH`, Recommended Cloud-Native Default):** Full line-rate routing across spokes via NCC `MESH`, with microsegmentation enforced by Firewall Policies, Secure Tags, and/or **Cloud NGFW Enterprise**.\n   - **Option B — Central Hub Group + Isolated Edge Spokes over NCC (`hub-and-spokes-ncc` with `STAR`):** Shared-service/hub VPCs in the `center` group are reachable by all `edge` spokes (`dev`, `prod`), while `edge` spokes are isolated from each other at the routing table layer (strongly favored over traditional peering for scale and transitive hybrid/PSA/PSC routing).\n   - **Option C — Inspected via 3rd-Party Stateful NVA VMs (Palo Alto, Fortinet, Check Point, Linux/FRR):** Spoke-to-spoke and hybrid traffic is hairpinned through multi-NIC NVA VMs.\n     - **Always recommend `hub-and-spokes-nva-ncc` (NCC Router Appliance with dynamic BGP) for multi-regional architectures:** It is the **only** pattern that provides automatic cross-region failover via BGP/MED policies *and* allows managed services (such as PSA-peered Cloud SQL, AlloyDB, or NetApp) to use the best available regional route, since managed services do not support network-tagged static routes.\n     - Use **`hub-and-spokes-nva` (ILB Sandwich with static routes)** only for single-region deployments or specific point-to-point/DMZ `ILB-as-next-hop` legs where BGP is not used.\n   - **Option D — Strictly Isolated Spokes via VPC Peering (`hub-and-spokes-peerings`):** Small static estate (`<= 5` spokes, `$0` hub fee) where `dev` and `prod` peer only with `hub`.\n     - *Advisory warning:* Remind the user that **\"strict spoke isolation\" almost never holds true over time**—scenarios like exchanging anonymized data between `prod` and `dev`, or running shared CI/CD or AI agents that must operate across both environments, will bite them under non-transitive peering.\n\n### Turn 3 — Path B (`Multi-VPC Hub & Spokes`): Hybrid, Egress, Firewall & Workload Plumbing\nOnce the Multi-VPC pattern is selected in Turn 2B, ask the remaining plumbing questions:\n1. **On-Premises / Multicloud Links:** Do you need **HA VPN**, **Cloud Interconnect (VLAN attachments)**, both, or **None (Cloud-only)**?\n   - *(Note: On NCC hybrid spokes, `site_to_site_data_transfer` defaults to `true` in `2-networking`; ask whether external sites need site-to-site transit through GCP (`true`, default) or only communicate with GCP VPCs (`false`, which also avoids errors in regions where S2S is unsupported). If Cloud Interconnect is selected, gather or propose Dedicated vs. Partner mode and `peer_asn`).*\n2. **Internet Egress Model:** Per-VPC **Cloud NAT** (`nat_config`), centralized egress through the NVA/DMZ VPC, or **no internet egress** (Air-gapped / PGA only)?\n3. **Cloud NGFW Enterprise (if Cloud-Native routing was selected in Turn 2B):** Standard L4 Firewall Policies (`goto_next`), or add **Cloud NGFW Enterprise** (`$security_profile_groups` L7 IPS rules)?\n4. **Workload & Managed Service Plumbing:** **None (standard subnets only)**, **GKE** (`pods`/`services` secondaries), **L7 ALB** (`/23` `proxy_only: true`), **PSA** (`psa_configs`), **PSC** (`psc: true` NAT subnets / `psc:` consumer endpoints in `addresses.yaml`), and/or **Serverless / Vertex AI**?\n\n---\n\n## 3. Mapping Requirements to `2-networking` Topologies\n\n```text\n  [Primary VPC Footprint Fork: Single VPC or Multi-VPC Hub & Spokes?]\n   ├── Single Shared VPC (PoCs, simple estates, or single IAM boundary)\n   │    └──> Custom Single-VPC Dataset (1 project + 1 VPC, no NCC/Peering/DNS-peering; derived from peerings dataset)\n   │\n   └── Multi-VPC Hub & Spokes (Separate host-project IAM boundaries or horizontal quota scale)\n        ├── [Do inter-VPC or hybrid flows require 3rd-party multi-NIC NVA VMs as the transit router?]\n        │    ├── Multi-region NVA (Recommended Default) ──> datasets/hub-and-spokes-nva-ncc (BGP Router Appliance)\n        │    ├── Single-region NVA / Static ILB only ─────> datasets/hub-and-spokes-nva (ILB Sandwich)\n        │    └── Hybrid (NCC-RA core + ILB DMZ leg) ──────> datasets/hub-and-spokes-nva-ncc (+ ilb_config on DMZ NIC)\n        │\n        └── [Cloud-native routing, with L4 Firewall Policies and/or Cloud NGFW Enterprise]\n             ├── Recommended Default (Any scale, hybrid/PSA/PSC transitivity) ──> datasets/hub-and-spokes-ncc\n             │    ├── Full spoke-to-spoke routing (microsegmented via FW/NGFW) ─> preset_topology: MESH\n             │    └── Routing-level isolation between edge spokes via center ───> preset_topology: STAR\n             │\n             ├── Small static estate (<= 5 spokes, $0 hub fee) ─────────────────> datasets/hub-and-spokes-peerings\n             │    (Warn user: strict isolation almost always bites later—e.g. exchanging anonymized prod/dev data or running agents across both envs)\n             │\n             └── Legacy / Niche (Not recommended for new designs; NCC wins) ────> datasets/hub-and-spokes-vpns\n```\n\n### Topology Comparison Matrix\n\n| Topology Pattern & In-Tree Baseline | Core Connectivity & VPC Layout | East-West Behavior | Scale & Quota Envelope | Best Fit & Recommendation Posture | Watch Out For... |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| **Single Shared VPC** *(Derived by simplifying `hub-and-spokes-peerings` to 1 project + 1 VPC)* | Single Shared VPC (`vpcs/main/` or `vpcs/shared/`) in 1 host project (`projects/net-core-0.yaml`). Zero `ncc-hubs/`, `nvas/`, or `peering_config`. | **Native intra-VPC routing** at full line rate; segmented via subnet IAM, Firewall Policies, Secure Tags, and/or Cloud NGFW Enterprise. | Up to **300 subnets** and **100–1,000 service projects** in a single quota domain. | **Ideal for PoCs, simple/compact estates, or low-latency enclaves** where a single network admin team manages the host project. | All environments share a single Shared VPC host project IAM boundary and VPC quota domain; intra-VPC 3rd-party appliance hairpinning requires Policy-Based Routing (`policy_based_routes`). |\n| **`hub-and-spokes-ncc`** *(Recommended Multi-VPC Cloud-Native Default)* | Central NCC Hub (`hub.yaml`) connecting `hub-0` (with hybrid VPN/VLAN spokes), `dev-0`, and `prod-0` as VPC spokes. | **Configurable:** Full any-to-any with `preset_topology: MESH`, or isolated edge spokes with `preset_topology: STAR` (`center` vs. `edge` groups). | Scales up to **250 VPC spokes** per hub (`4,000` subnet routes; `1,000` dynamic prefixes/region; `1,000` propagated PSC endpoints). | **Primary recommendation** for multi-VPC cloud-native and Cloud NGFW Enterprise estates; native transitive hybrid routing, PSC propagation (`export_psc: true`), and Producer VPC Spoke support. | `preset_topology` (`MESH` vs. `STAR`) is immutable after hub creation; `site_to_site_data_transfer` on hybrid spokes defaults to `true` (set `false` in regions that do not support S2S). |\n| **`hub-and-spokes-nva-ncc`** *(Recommended Multi-Region NVA Default)* | Dual NCC Hubs (`routing` `MESH` + `transit` `STAR`); multi-region NVAs (`nvas/primary.yaml`, `secondary.yaml`) attached as **NCC Router Appliance** spokes (`ncc_ra_config` + `bgp_config`). | **Inspected:** `dev-0` and `prod-0` attach to `transit` `edge` group; NVAs attach to `transit` `center` and `routing` `default`, inspecting all N-S and E-W flows over dynamic BGP. | Scales to NCC limits (**250 spokes**) with zero static routes and automatic intra-region / cross-region BGP failover. | **Always recommend for multi-region 3rd-party NVA architectures.** Only way to get automatic cross-region failover and let managed services (`PSA`) follow the best available route (since managed services do not support tagged static routes). | Never place workloads in the `transit` VPC (asymmetric return path); every prefix in `advertise` (control plane) must have a matching kernel route in `routes` (data plane). |\n| **`hub-and-spokes-nva`** | Multi-NIC NVA (`nvas/main.yaml`) with `ilb_config` directly attached via 4 NICs to `dmz`, `hub-0`, `dev-0`, and `prod-0` (no `peering_config` between VPCs). | **Inspected:** Static routes in each VPC steer inter-VPC and hybrid traffic to the NVA's per-VPC Internal Passthrough NLB VIP (`$addresses:<vpc>/main`). | Bounded by GCE maximum NICs per VM (`2` to `8` vNICs depending on machine shape, so at most 8 directly attached VPCs) and NVA VM bandwidth. | Single-region 3rd-party NVA deployments with $\\le 8$ VPCs, or specific `ILB-as-next-hop` DMZ/cross-environment legs where dynamic BGP is not used. | Static routes lack regional proximity metrics for multi-region failover; requires symmetric 5-tuple hashing (`PER_CONNECTION`, `session_affinity: NONE`, `NEVER_PERSIST`) and a shared health check. |\n| **`hub-and-spokes-peerings`** *(also symlinked as `classic`)* | `hub-0` peered via `peering_config` with `dev-0` and `prod-0` (`routes_config.export/import: true`). | **Isolated** (VPC Peering is strictly non-transitive). | Small estates (**$\\le 5$ spokes**). Shares peering-group quotas (`subnet_ranges_per_peering_group` = 1,000; `static_routes_per_peering_group` = 300). | Small, cost-sensitive setups (`$0` hub fee). **Caution:** Warn the user that \"strict spoke isolation\" is almost never true long-term—scenarios like exchanging anonymized data between `prod` and `dev` or running agents that work across both environments will bite them under non-transitive peering. | On-premises routes in `hub-0` cannot transit to PSA (`servicenetworking`) peered to `dev-0`/`prod-0`; Cloud DNS forwarding (`35.199.192.0/19`) cannot hop across peering to a VPN in another VPC. |\n| **`hub-and-spokes-vpns`** *(Legacy / Niche)* | `hub-0` connected to `dev-0` and `prod-0` via GCP-to-GCP HA VPN tunnels (`vpcs/<vpc>/vpns/*.yaml`) and Cloud Router BGP. | **Policy-Controlled:** Spokes exchange routes through `hub-0` Cloud Routers, filtered via CEL `route_policies` (`IMPORT`/`EXPORT`). | Up to 5 HA VPN gateways/region/project; bandwidth capped at **1.5–3 Gbps per IPsec tunnel** (`1460` byte MTU). | **Avoid for modern greenfield designs (NCC wins on throughput, cost, and simplicity).** Retain only for legacy brownfield compatibility or hard cross-org IPsec requirements. | IPsec tunnel throughput ceiling and MTU clamping (`1460` / `1406`); VPC firewall rules must allow `icmp` ingress so Path MTU Discovery (`ICMP Type 3 Code 4`) functions. |\n\n---\n\n## 4. Phase 2 (Step 3): Unified Topology, IPAM & Plumbing Proposal Template\n\nDo **not** split Topology Confirmation and IPAM Confirmation into two separate turns. As soon as Phase 1 requirements are gathered (or extracted upfront in Adaptive Mode), present a **single unified proposal** containing both the selected topology and pre-filled default CIDRs/ASNs/DNS domains, and ask the user once to confirm (or specify any custom CIDRs/names/output path) before generating the dataset in Phase 3:\n\n```markdown\n### Proposed FAST `2-networking` Topology & IPAM Blueprint\n\n- **Topology Pattern:** [`Single Shared VPC` | `datasets/<chosen-dataset>`]\n- **Target Output Directory:** `<proposed-or-user-specified-path>` *(e.g. `./fast-config/2-networking/data/`)*\n- **Stage 0 Integration:** [FAST `0-org-setup` (`$folder_ids:networking`) | Standalone `.tfvars`]\n- **Regions (`context.locations`):** `<key-1>` = `<region-1>` [, `<key-2>` = `<region-2>`, ...]\n\n#### 1. Proposed Projects, VPCs & Regional IPAM Allocation (Pre-Filled Defaults)\n| Project Key (`projects/`) | VPC Key (`vpcs/`) | Region (`$locations:`) | Subnet / Range Name | Proposed CIDR | Purpose & Stage 2 Plumbing |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| `net-core-0` | `main` (or `hub` / `routing`) | `<key-1>` | `<subnet-name>` | `10.64.0.0/24` | [Primary subnet / HA VPN / Cloud NAT / DNS] |\n| *(Additional rows for secondary regions, `proxy_only`, `psc`, `psa_configs`, GKE `pods`/`services`, and spoke VPCs as applicable)* | | | | | |\n\n#### 2. Routing, BGP, DNS & Firewall Summary\n- **East-West & Hybrid Routing:** [1–2 bullets including proposed Cloud Router / Peer BGP ASNs if VPN/Interconnect/NCC-RA is used]\n- **Cloud DNS & Firewall Posture:** [Proposed private/forwarding/peering DNS domains (`gcp.example.com.`, `onprem.example.com.`), RPZ, and Hierarchical/VPC Firewall rules]\n- **Downstream Stage Handoffs (Out of Scope for Stage 2):** [List any requested ALBs, Cloud NGFW Enterprise endpoints/profiles, GKE clusters, or Cloud SQL instances that will consume the Stage 2 plumbing in follow-up stages, or `None`]\n```\n\nAsk the user to confirm this unified blueprint (or tell you any CIDRs, ASNs, domain names, Interconnect details, or output path they want changed) before executing **Phase 3 (Dataset Generation & Validation)**.\n\n<!-- File: skills/fast/networking/references/2-routing-and-security-mechanisms.md (Tokens: ~5155 | Category: anthropic-skill) -->\n# Phase 2 Reference: Routing and Security Mechanisms Across Topologies\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)**. Once the baseline topology is selected in `1-requirements-to-topology.md`, use this guide to explain and configure how North-South, East-West, Hybrid, Egress, DNS, and Inspection mechanisms work in YAML.\n\n---\n\n## 1. Cross-Topology Mechanism Matrix\n\n| Mechanism | `Single Shared VPC` *(Simple / PoC / Single-IAM)* | `hub-and-spokes-ncc` *(Multi-VPC Cloud-Native Default)* | `hub-and-spokes-nva-ncc` *(Multi-Region NVA Default)* | `hub-and-spokes-nva` *(Single-Region ILB Sandwich)* | `hub-and-spokes-peerings` *(Small Static Estate)* | `hub-and-spokes-vpns` *(Legacy / Niche)* |\n| :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n| **Spoke-to-Hub Routing** | N/A (single VPC `vpcs/main/`; no hub or peering needed). | `ncc_config` on hub & spokes (`hub: $ncc_hubs:hub`). | Dual NCC hubs (`routing` `MESH` + `transit` `STAR`); spokes attach to `transit` `edge` group. | Direct multi-NIC NVA attachment across `dmz`, `hub`, `dev`, `prod` + static routes to `$addresses:<vpc>/main` (no VPC peering). | Reciprocal `peering_config` on both hub & spoke (`routes_config.export/import: true`, enabled by default). | `vpcs/<vpc>/vpns/*.yaml` with reciprocal `$vpn_gateways:` + BGP. |\n| **East-West / Spoke Routing** | **Native intra-VPC routing**; isolated via Firewall Policies, Secure Tags, or Cloud NGFW. | **Direct** (`MESH` preset) or **Blocked at routing layer** (`STAR` `edge` group). | **Inspected via NVA:** Spokes in `transit/edge` route through NVAs in `transit/center` via BGP origination (`advertise` + `routes`). | **Inspected via NVA:** Spoke static routes point inter-spoke prefixes to the local spoke ILB VIP (`$addresses:<spoke>/main`). | **Blocked by design** (VPC Peering is strictly non-transitive). | Filtered via Cloud Router BGP `route_policies` (`IMPORT`/`EXPORT`) in `hub`. |\n| **On-Prem Hybrid Transit** | Terminates directly in `main` (`vpcs/main/vpns/` or `vlan-attachments/`); zero transitivity hops. | Terminates in `hub`; `ncc_spoke_config` on VPN/VLAN attaches tunnels/VLANs as NCC hybrid spokes. | Terminates in `routing` VPC on `routing` `MESH` hub; NVA inspects between `routing` and `transit` hubs. | Terminates in `dmz` or `hub` VPC; static routes steer traffic through NVA ILBs to spokes. | Terminates in `hub`; spokes import custom routes via peering (`routes_config.import: true`). | Terminates in `hub`; re-advertised to spokes over inter-VPC BGP. |\n| **Internet Egress** | `nat_config` (Cloud NAT) and PGA on `main`. | Per-VPC `nat_config` (Cloud NAT) and Private Google Access (`PGA`). | Per-VPC `nat_config` or centralized `0.0.0.0/0` originated on `routing` CR $\\rightarrow$ NVA (`masquerade_exclude`) $\\rightarrow$ Cloud NAT. | Per-VPC `nat_config` or centralized default route via NVA to `dmz`/`untrusted` NIC (`masquerade: true`) + Cloud NAT. | Per-VPC `nat_config` (Cloud NAT) and PGA. | Per-VPC `nat_config` or default route over hub VPN. |\n| **Firewall & Inspection** | Hierarchical policy (`goto_next`) + VPC rules + Secure Tags (+ optional Cloud NGFW or PBR). | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Stateful 3rd-party NVA inspection via BGP RA spokes (active-passive MED or active-active ECMP) + VPC/Hierarchical rules. | Stateful 3rd-party NVA inspection via ILB next-hop (`PER_CONNECTION`, `NONE` affinity, `NEVER_PERSIST`) + VPC/Hierarchical rules. | Hierarchical policy (`goto_next`) + VPC firewall rules (+ optional Cloud NGFW `$security_profile_groups`). | Hierarchical policy + VPC firewall rules + BGP prefix filtering. |\n| **Cloud DNS Pattern** | All `private` and `forwarding` zones bind directly to `client_networks: [$networks:main]` (no DNS peering needed). | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub` + RPZ. | Per-spoke private zones bound to spoke + `routing`; `routing` hosts on-prem forwarding; `transit` & spokes peer `.` to `routing` + RPZ. | Per-spoke private zones bound to spoke + `hub`; `dmz` & spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; `hub` hosts on-prem forwarding; spokes peer `.` to `hub`. | Per-spoke private zones bound to spoke + `hub`; spokes peer `.` to `hub`. |\n\n---\n\n## 2. Cloud-Native Routing Mechanics (`single-vpc`, `ncc` & `peerings`)\n\n### 2.0 Single Shared VPC (`single-vpc`)\n- **Dataset Structure:** Derived by simplifying `hub-and-spokes-peerings` to a single host project (`projects/net-core-0.yaml` with `shared_vpc_host_config.enabled: true`) and a single VPC (`vpcs/main/.config.yaml`). Omit `ncc-hubs/`, `nvas/`, `peering_config`, and `ncc_config`.\n- **Environment Segmentation without Multiple VPCs:**\n  - Create either general-purpose regional subnets or per-environment regional subnets (e.g., `vpcs/main/subnets/dev-ew8.yaml`, `prod-ew8.yaml`) inside `vpcs/main/subnets/`, delegating subnet-level `roles/compute.networkUser` IAM to downstream service projects.\n  - Enforce East-West isolation between environments or tiers using Hierarchical/VPC Firewall Policies, IAM-governed Secure Tags (`$tag_values:`), and optional Cloud NGFW Enterprise (`$security_profile_groups:`).\n- **Direct Hybrid, PSA & DNS Simplicity:**\n  - HA VPNs (`vpcs/main/vpns/`), VLAN attachments (`vpcs/main/vlan-attachments/`), and Private Services Access (`psa_configs`) all attach directly to `main`, eliminating inter-VPC transitivity constraints.\n  - All Cloud DNS `private` and `forwarding` zones in `dns/zones/` bind directly to `client_networks: [$networks:main]`, and `dns/response-policies/*.yaml` lists only `networks: [$networks:main]`—do **not** create a `peer-root.yaml` DNS peering zone in a Single VPC dataset.\n\n### 2.1 Network Connectivity Center (`hub-and-spokes-ncc`)\n- **`MESH` vs. `STAR` Preset Topology (`ncc-hubs/<hub>.yaml`):**\n  - `preset_topology: MESH` (default) places all spokes in `groups.default`, enabling direct line-rate routing between every spoke (microsegmented via Firewall Policies, Secure Tags, or Cloud NGFW Enterprise).\n  - `preset_topology: STAR` creates two groups (`center` and `edge`). Attach shared-service/transit VPCs to `$ncc_groups:<hub>/center` and workload environment VPCs (`dev`, `prod`) to `$ncc_groups:<hub>/edge` when you want routing-table isolation between workload spokes while sharing central services.\n  - *Constraint:* `preset_topology` is immutable after hub creation.\n- **PSC Propagation:** `export_psc` defaults to `true` in `factory-ncc.tf` so Private Service Connect consumer endpoints created in one spoke (such as `hub`) are automatically reachable from all connected VPC spokes.\n- **NCC Hybrid Spokes (`vpns/*.yaml` & `vlan-attachments/*.yaml`):**\n  - Adding `ncc_spoke_config: { hub: $ncc_hubs:hub }` to a VPN or VLAN attachment file registers it as an NCC hybrid spoke (`factory-ncc.tf` automatically configures `include_import_ranges = [\"ALL_IPV4_RANGES\"]` so Cloud Routers import dynamic routes from the hub and advertise spoke subnets back to on-premises peers).\n  - **`site_to_site_data_transfer` (`true` default vs. `false`):** In `factory-ncc.tf`, `vpn.schema.json`, and `vlan-attachments.schema.json`, `site_to_site_data_transfer` **defaults to `true`**, allowing two hybrid spokes attached to the hub (for example, an On-Premises VPN/Interconnect and a colocated Partner Interconnect) to exchange routes and traffic directly through Google Cloud. Explicitly set `site_to_site_data_transfer: false` when external sites only communicate with GCP VPCs or when deploying in a GCP region that does not support NCC site-to-site data transfer.\n\n### 2.2 VPC Network Peering (`hub-and-spokes-peerings`)\n- **Reciprocal Declaration:** Peering must be declared in **both** VPCs' `.config.yaml` files (`hub` $\\rightarrow$ `prod` and `prod` $\\rightarrow$ `hub`).\n- **Custom Route Exchange:** `factory-peering.tf` defaults `routes_config.export` and `routes_config.import` to `true` so spokes automatically import on-premises prefixes learned by `hub` over VPN/Interconnect.\n- **Non-Transitivity Traps:**\n  1. Workloads in `dev` cannot reach `prod` over `hub` (and if they later need to exchange anonymized data or run shared agents across both environments, peering cannot route it without an intermediate proxy/NVA or migrating to NCC).\n  2. On-premises networks terminating in `hub` cannot reach Private Services Access (`psa_configs` / `servicenetworking`) peered to `dev` or `prod`.\n  3. Cloud DNS outbound forwarding packets (`35.199.192.0/19`) cannot traverse a VPC peering link to reach a VPN/Interconnect in another VPC—always host the forwarding zone in the VPC that terminates the hybrid link (and use DNS peering from spokes).\n\n---\n\n## 3. 3rd-Party NVA Routing & Failover Mechanics (`nva-ncc` vs. `nva`)\n\n### 3.1 Multi-Region NVA via NCC Router Appliance (`hub-and-spokes-nva-ncc`)\nWhenever 3rd-party stateful NVA VMs are used in a **multi-regional architecture** (or alongside PSA managed services), recommend `hub-and-spokes-nva-ncc`:\n\n```text\n  [On-Prem VPN/VLAN] ──> ( routing VPC ) <──BGP── [ NVA nic0 ]\n                              │                       │ (Stateful Inspection)\n                        (routing MESH hub)            ▼\n                                                [ NVA nic1 ] ──BGP──> ( transit VPC )\n                                                                            │\n                                                                 (transit STAR hub: center)\n                                                                            │\n                                                                 (transit STAR hub: edge)\n                                                                      ├── ( dev VPC )\n                                                                      └── ( prod VPC )\n```\n\n1. **Dual-Hub Architecture:**\n   - **`routing` Hub (`MESH`):** Connects hybrid spokes (HA VPN / VLAN attachments in the `routing` VPC) and `nic0` of each regional NVA as a Router Appliance spoke.\n   - **`transit` Hub (`STAR`):** Connects `nic1` of each regional NVA to the `center` group (`$ncc_groups:transit/center`) and workload spoke VPCs (`dev`, `prod`) to the `edge` group (`$ncc_groups:transit/edge`).\n   - *Critical Rule:* **Never host workloads in the `transit` VPC.** The `transit` VPC is the routing VPC for the `transit` RA spokes and learns spoke subnet routes directly from the hub; a VM inside `transit` would send traffic to a spoke via the NVA, while return traffic from the spoke would bypass the NVA and go directly to the `transit` subnet (asymmetric drop).\n2. **`routes` (Data Plane) vs. `advertise` (Control Plane) in `nvas/*.yaml`:**\n   - `routes` installs static Linux kernel routes inside the NVA guest OS via that NIC's subnet gateway so the OS knows which interface forwards to a prefix.\n   - `advertise` originates prefixes in FRR via BGP `network` statements toward Cloud Router peers (used on `nic1` in `transit` so edge spokes—which do not exchange subnet routes with each other in a `STAR` hub—learn routes to other spokes through the NVA).\n   - *Invariant:* Because FRR prefers its own locally originated `network` statement (`weight 32768`) over eBGP routes learned from Cloud Router (`weight 0`), **every prefix listed in `advertise` must be covered by a kernel route in `routes`** (or the default route on a `masquerade` NIC), otherwise the NVA kernel will lack a forwarding entry and blackhole the traffic.\n3. **Active-Passive (No Session Sync) vs. Active-Active ECMP:**\n   - **Active-Passive + Regional Affinity (Default in `hub-and-spokes-nva-ncc`):** Configure explicit per-instance BGP peers on the Cloud Routers using `$nva_addresses:<nva>-<zone>-<index>/<vpc>` and `$nva_instances:<nva>-<zone>-<index>`, attaching distinct `IMPORT` route policies (`med.set(100)` for primary zone `b`, `med.set(150)` for standby zone `c`, and `med.set(10100)` / `10150` for remote-region prefixes). This guarantees symmetric forward and return paths through the same regional primary NVA without requiring cross-instance state synchronization, while failing over automatically within the region or across regions if an appliance or region fails.\n   - **Active-Active ECMP:** Configure a fan-out peer (`nva: <nva-key>`) on the Cloud Router interfaces sharing a single import policy. Both zonal instances advertise at equal MED; use only when the NVA performs stateless inspection or runs active-active state synchronization (e.g., FGSP) and SNAT is excluded for private ranges (`masquerade_exclude`).\n4. **Why `NCC-RA` Wins for Managed Services (`PSA`):**\n   - Managed service producer networks (`servicenetworking` for Cloud SQL, AlloyDB, Memorystore, NetApp) do not have GCE network tags and cannot use network-tagged static routes. Because `NCC-RA` injects dynamic BGP routes into the VPC route table with regional MED priorities, PSA-peered services automatically use the local region's healthy NVA and fail over to the secondary region dynamically.\n\n### 3.2 Single-Region ILB Sandwich (`hub-and-spokes-nva`) & Hybrid `NCC-RA` + `ILB` Chaining\n- **When to use `ilb_config`:** Single-region NVA deployments (`hub-and-spokes-nva`, where `dmz`, `hub`, `dev`, and `prod` are all attached directly to the NVA's 4 NICs without VPC Peering), or when attaching a 3rd NIC (`nic2`) of an `NCC-RA` NVA to a dedicated DMZ or cross-environment bridge VPC using Internal Passthrough NLB next-hop static routes (`$addresses:<vpc>/<nva-name>`).\n- **Mandatory Conntrack & Health Check Guardrails:**\n  - The stage's `factory-nva.tf` configures a single shared health check and defaults `ilb_config.connection_tracking` to `track_per_session: false` (`PER_CONNECTION` 5-tuple tracking) and `persist_conn_on_unhealthy: NEVER_PERSIST` (with `session_affinity: NONE` and 600s idle timeout). Never override `ilb_config.connection_tracking` with `track_per_session: true` (`PER_SESSION` 2-tuple hashing causes asymmetric forward/return selection across NVA instances during health flaps).\n- **Intra-VPC Hairpinning with Policy-Based Routing (PBR):**\n  - If traffic *within* a single VPC (or entering a VPC from a specific source CIDR) must be forced through an NVA ILB, configure `policy_based_routes` in `vpcs/<vpc>/.config.yaml` pointing `next_hop_ilb_ip` to `$addresses:<vpc>/<nva-name>`.\n  - *PBR Guardrail:* Always include a higher-priority (lower numeric `priority` value) rule with `use_default_routing: true` matching Private Service Connect / Google API ranges first so wildcard PBR (`0.0.0.0/0`) does not hijack local PSC/PGA traffic into the NVA.\n\n---\n\n## 4. Firewall Policies & Cloud NGFW Enterprise\n\n1. **Hierarchical & Network Firewall Policies (`firewall-policies/*.yaml`):**\n   - Both Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>` or `organizations/<id>`) and Network Firewall Policies (`parent_id: $project_ids:<key>` with `region: global` or `$locations:<key>`) are defined in `firewall-policies/*.yaml` with an `attachments:` map inside the policy YAML file.\n   - Rules in `modules/net-firewall-policy` default to `action: allow` (ingress) or `action: deny` (egress) if `action` is omitted; always set `action: goto_next` explicitly on rules that should delegate evaluation to lower-level policies or VPC rules.\n   - Supports symbolic CIDR sets (`$cidr_ranges_sets:healthchecks`, `$cidr_ranges_sets:rfc1918`) defined in `defaults.yaml`.\n2. **VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n   - **Rule Key Uniqueness per Project:** GCE firewall rule names are project-scoped. If two VPCs share the same `project_id` (for example, `routing` and `transit` both living in `$project_ids:net-core-0`), their firewall rule keys across `vpcs/routing/firewall-rules/` and `vpcs/transit/firewall-rules/` **must be distinct** (e.g., `allow-routing-ssh` vs. `allow-transit-ssh`) to avoid HTTP 409 conflicts.\n   - **ICMP for PMTUD on VPN VPCs:** Any VPC terminating or transiting HA VPN tunnels must allow `icmp` ingress (especially `ICMP Type 3 Code 4` Fragmentation Needed) so Path MTU Discovery adjusts TCP MSS for in-flight connections across the `1406`/`1460`-byte IPsec tunnel MTU.\n3. **Cloud NGFW Enterprise Integration (L7 IPS / TLS Inspection):**\n   - In `firewall-policies/*.yaml`, rules can set `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>` (where `var.security_profile_groups` is passed from a `2-networking-ngfw` or security stage).\n\n---\n\n## 5. Cloud DNS & Name Resolution Mechanics\n\n1. **Private & Hybrid DNS Topologies (`dns/zones/`):**\n   There are three main patterns for structuring private and forwarding zones across hub and spoke VPCs:\n   - **Pattern A — Per-Environment Zones Bound to Hub + Root Peering (Recommended Default, used in the FAST reference datasets):**\n     1. Each environment manages its own private zone in its own spoke project (e.g., `dns/zones/net-dev-0/pvt-dev.yaml` with `project_id: $project_ids:net-dev-0`) and uses **cross-project VPC binding** (`private.client_networks: [$networks:hub, $networks:dev]`) to bind the zone to both the spoke VPC and the centralized hub VPC (`hub` or `routing`).\n     2. The centralized hub VPC—which **must** be the VPC where hybrid VPN/Interconnect connectivity terminates so Cloud DNS forwarding packets (`35.199.192.0/19`) have a direct path to on-premises resolvers—hosts the on-premises forwarding zone (`forwarding.client_networks: [$networks:hub]`) and top-level shared private zones.\n     3. All spoke VPCs attach to a root peering zone (`domain: .`, `peering.peer_network: $networks:hub`, `client_networks: [$networks:dev, $networks:prod]`)—or, alternatively, bind the forwarding zone directly across VPCs when routing allows. Root peering to the centralized VPC is **preferred** because spokes automatically resolve on-premises domains and all environment zones bound to the hub.\n   - **Pattern B — Fully Centralized Zones + Root Peering:**\n     - All private zones and on-premises forwarding zones are defined centrally in `net-core-0` and bound to the centralized hub VPC (`$networks:hub` or `$networks:routing`), while every spoke VPC peers root (`domain: .`) to the centralized VPC.\n   - **Pattern C — Cross-Project Bindings Only (`client_networks`):**\n     - Private zones (defined either centrally in `net-core-0` or per spoke project) are bound directly to all consumer VPCs via `private.client_networks: [$networks:hub, $networks:dev, $networks:prod]` (requires cross-project IAM permissions, which Stage 2 manages when all projects are in the dataset).\n2. **Cloud DNS Response Policies (`dns/response-policies/*.yaml`):**\n   - Used to steer Google APIs (`*.googleapis.com.`, `*.gcr.io.`, `*.pkg.dev.`) to `private.googleapis.com.` (`199.36.153.8/30`), `restricted.googleapis.com.` (`199.36.153.4/30`), or a custom PSC Google API endpoint IP.\n   - **Regional Endpoint (`*.rep.googleapis.com.`) Bypass:** Cloud DNS Response Policies match by **most-specific DNS suffix** (there is no `priority` attribute in `dns-response-policy-rules.schema.json`). When workloads use GCP Regional Endpoints (`<service>.<region>.rep.googleapis.com.`), include a rule for `dns_name: \"*.rep.googleapis.com.\"` with `behavior: bypassResponsePolicy`—because `*.rep.googleapis.com.` is more specific than `*.googleapis.com.`, it automatically bypasses the wildcard CNAME override so regional TLS certificate validation succeeds.\n   - **PSC Interfaces (`PSC-I`) & Root / `googleapis.com.` Peering:** If a spoke VPC uses PSC Interfaces (`network_attachments` for Vertex AI Agent Engine with VPC-scoped DNS), note that DNS Peering takes precedence over local private zones; avoid peering `googleapis.com.` (or use Pattern C cross-project bindings + RPZ on that spoke) so local PSC-I resolution is not overridden.\n\n<!-- File: skills/fast/networking/references/3-stage-boundaries-and-plumbing.md (Tokens: ~3172 | Category: anthropic-skill) -->\n# Phase 2 Reference: Stage 2 Boundaries & Downstream Plumbing Contract\n\n`fast/stages/2-networking` is a **foundational network plumbing stage**. It creates the projects, VPCs, subnets, IP reservations, routers, interconnects/VPNs, NCC hubs/spokes, base NVAs, DNS zones, and firewall policies that downstream stages consume.\n\nWhen a user states a requirement for a security capability, load balancer, container platform, or managed database, **never** invent unsupported YAML keys inside `2-networking` and **never** tell the user FAST cannot support it. Instead:\n1. Configure the **Stage 2 network plumbing** required by that service in the YAML dataset.\n2. Explain what is delegated to the **follow-up stage** (`1-vpcsc`, `2-networking-ngfw` / security stage, `2-project-factory`, or workload stages).\n3. Document every delegated item in the generated dataset's `README.md` under a **Downstream Stage Handoffs** section.\n\n---\n\n## 1. Stage Boundary Principle & Representative Plumbing Matrix\n\n**General Boundary Principle:** Any GCP service or security capability whose data-plane or control-plane depends on shared VPC primitives (subnets, secondary ranges, special-purpose subnets, IP reservations, PSA peerings, PSC network attachments, routes, DNS zones/RPZ, or firewall policy rules) has those **foundational network primitives provisioned in `2-networking`** (per `fast/stages/2-networking/schemas/*.schema.json`), while the **service, endpoint, or workload resource itself is provisioned in a follow-up stage**.\n\nThe table below shows representative examples of how common capabilities split across `2-networking` and downstream stages:\n\n| Requested Capability | What `2-networking` Provisions (Stage 2 YAML Plumbing) | What is Delegated to Other Stages (Out of Scope for `2-networking`) |\n| :--- | :--- | :--- |\n| **Cloud NGFW Enterprise** *(L7 IPS, Threat Prevention, TLS Inspection)* | • Hierarchical or network firewall policy rules in `firewall-policies/*.yaml` setting `action: apply_security_profile_group` and `security_profile_group: $security_profile_groups:<key>`.<br/>• Context mapping via `var.security_profile_groups` in `.tfvars`. | • `google_network_security_firewall_endpoint`, `firewall_endpoint_association`, and `security_profile_group` resources (provisioned in a dedicated **`2-networking-ngfw` / security stage**). |\n| **Application Load Balancers** *(Internal L7 ALB, Regional External ALB, Service Extensions)* | • Regional proxy-only subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `proxy_only: true`, `/23` recommended, `/26` GCP minimum per region/VPC).<br/>• Static VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal`, `external`, or `global`).<br/>• Firewall rules allowing health-check probers (`$cidr_ranges_sets:healthchecks`: `35.191.0.0/16`, `130.211.0.0/22`) and proxy subnet CIDRs. | • Load balancer forwarding rules, target HTTP(S) proxies, URL maps, backend services, NEGs, and Cloud Armor policies (`modules/net-lb-app-*`, deployed in **workload stages**). |\n| **Internal Passthrough NLBs** *(Application-Tier L4 ILBs)* | • Workload subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`).<br/>• Static internal VIP reservations in `vpcs/<vpc>/addresses.yaml` (`internal:`).<br/>• Health-check allow rules (`35.191.0.0/16`, `130.211.0.0/22`). | • Application-tier `modules/net-lb-int` resources (deployed in **workload stages**). *(Note: NVA transit ILBs ARE managed directly by `2-networking` via `nvas/*.yaml` `ilb_config`).* |\n| **GKE Clusters** *(Standard & Autopilot)* | • Shared VPC host enablement (`shared_vpc_host_config.enabled: true`) and `container.googleapis.com` API in `projects/net-*.yaml`.<br/>• Primary node subnets with `secondary_ip_ranges` (`pods` and `services`) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• Optional firewall rules for private control-plane webhooks/master CIDRs. | • Service project creation and Shared VPC subnet-level IAM attachment (`roles/compute.networkUser`, `roles/container.hostServiceAgentUser` in **`2-project-factory`**).<br/>• GKE cluster & nodepool provisioning (`modules/gke-*` in **workload stages**). |\n| **Private Service Connect (PSC) — Producer** *(Publishing Services Across VPCs/Orgs)* | • Dedicated PSC NAT subnets (`vpcs/<vpc>/subnets/<region>/<name>.yaml` with `psc: true`, `/24` recommended per service attachment).<br/>• Regional `proxy_only: true` subnet if the published producer LB is an Internal Regional ALB. | • Producer load balancer and `google_compute_service_attachment` (`modules/net-lb-*` in **producer workload stages**). |\n| **Private Service Connect (PSC) — Consumer** *(Consuming SaaS, Managed DBs, or Google APIs)* | • Consumer PSC endpoint IPs in `vpcs/<vpc>/addresses.yaml` under `psc:` (`subnet_self_link` + `service_attachment: { psc_service_attachment_link: ... }` for service attachments, or `global: true` + `target: all-apis\\|vpc-sc` for Google API bundles).<br/>• Private Cloud DNS zones/records in `dns/zones/` or RPZ rules in `dns/response-policies/`.<br/>• `export_psc: true` on `ncc-hubs/*.yaml` to propagate consumer endpoints across NCC spokes. | • Producer-side project allowlisting (`consumer_accept_lists`) if the producer is external. |\n| **Private Services Access (PSA)** *(Cloud SQL, AlloyDB, Memorystore, NetApp Volumes)* | • `servicenetworking.googleapis.com` API in `projects/net-*.yaml`.<br/>• `psa_configs` in `vpcs/<vpc>/.config.yaml` (allocating `/24`–`/16` internal CIDR ranges, `export_routes: true`, `import_routes: true`).<br/>• Cloud Router `custom_advertise.ip_ranges` if on-premises networks need to reach the PSA ranges.<br/>• Note: If multiple NCC spokes need transitive access to a PSA peering (`servicenetworking-googleapis-com` or `sn-netapp-prod`), document attaching an **NCC Producer VPC Spoke** (`linked_producer_vpc_network`) or using PSC endpoints instead. | • Managed database or storage instances (`modules/cloudsql-*`, `alloydb`, `netapp-volumes` in **workload stages**). |\n| **Serverless & Vertex AI Runtimes** *(Cloud Run Direct VPC Egress, Vertex AI Agent Engine PSC-I)* | • Dedicated subnets (`>= /26`, `/24` recommended to account for `2x` rollout IP reservation) in `vpcs/<vpc>/subnets/<region>/*.yaml`.<br/>• `network_attachments` in `vpcs/<vpc>/.config.yaml` for PSC Interfaces (`PSC-I`, referencing `<resolved-region>/<subnet-name>` and `automatic_connection`).<br/>• Cloud DNS RPZ bypass (`*.rep.googleapis.com.` `bypassResponsePolicy`) and ensuring `googleapis.com.` is **not** in `dns/zones/` as a DNS Peering zone on VPCs using `PSC-I`. | • Service project attachment in **`2-project-factory`**.<br/>• Cloud Run services, Vertex AI endpoints, and Agent Engine deployments in **workload stages**. |\n| **VPC Service Controls (VPC-SC)** *(Data Exfiltration Perimeters)* | • Plumbing for `restricted.googleapis.com` (`199.36.153.4/30`) routes and DNS response policies (`dns/response-policies/*.yaml`).<br/>• Optional `$vpc_sc_perimeters:` mapping on networking host projects (`var.perimeters`). | • Access Context Manager policies, access levels, and regular/bridge service perimeters (managed in **`1-vpcsc`**). |\n\n---\n\n## 2. Example YAML Plumbing Snippets for `2-networking`\n\nThe snippets below are **illustrative examples** showing how Stage 2 plumbing is expressed against the `fast/stages/2-networking/schemas/*.schema.json` schemas. Adapt names, keys, regions, and CIDRs to the user's design, and consult the JSON schemas in `fast/stages/2-networking/schemas/` for the full set of supported attributes. *(Note: when grouping subnets in regional folders under `vpcs/<vpc>/subnets/<region>/`, ensure every `.yaml` file within a VPC has a **unique filename stem**, e.g. `prod-proxy-ew1.yaml` and `prod-proxy-ew8.yaml`, because `modules/net-vpc` keys raw subnet files by basename).*\n\n### 2.1 Regional ALB Proxy-Only & PSC Producer Subnets (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-proxy-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-proxy-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.128.0/23\ndescription: Regional Managed Proxy subnet for Internal and Regional External ALBs\nproxy_only: true\nactive: true\n```\n\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-psc-nat-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-psc-nat-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.130.0/24\ndescription: PSC NAT subnet for publishing Service Attachments\npsc: true\n```\n\n### 2.2 GKE Node Subnet with Pod & Service Secondaries (`vpcs/<vpc>/subnets/<region>/*.yaml`)\n```yaml\n# vpcs/prod/subnets/europe-west1/prod-gke-ew1.yaml\n# yaml-language-server: $schema=../../../../../schemas/subnet.schema.json\n\nname: prod-gke-ew1\nregion: $locations:ew1\nip_cidr_range: 10.100.0.0/22\ndescription: Primary subnet for GKE nodes with secondary Pod and Service ranges\nsecondary_ip_ranges:\n  pods:\n    ip_cidr_range: 10.104.0.0/17\n  services:\n    ip_cidr_range: 10.108.0.0/21\n```\n\n### 2.3 PSA Ranges & PSC Interfaces (`network_attachments`) in `vpcs/<vpc>/.config.yaml`\n```yaml\n# Inside vpcs/prod/.config.yaml\npsa_configs:\n  - ranges:\n      cloudsql-psa: 10.110.0.0/20\n    export_routes: true\n    import_routes: true\n\nnetwork_attachments:\n  agent-engine-ew1:\n    # Must be '<resolved-region>/<subnet-name>' matching a subnet in this VPC:\n    subnet: europe-west1/prod-psc-int-ew1\n    automatic_connection: false\n    # Literal GCP project IDs (producer_accept_lists does not interpolate $project_ids):\n    producer_accept_lists:\n      - my-ai-workload-prod-0\n```\n\n### 2.4 Reserved VIPs & PSC Consumer Endpoints (`vpcs/<vpc>/addresses.yaml`)\n```yaml\n# vpcs/prod/addresses.yaml\n# yaml-language-server: $schema=../../../schemas/addresses.schema.json\n\ninternal:\n  # Referenced in routes/PBR as $addresses:prod/app-ilb-ew1\n  app-ilb-ew1:\n    region: $locations:ew1\n    subnetwork: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.10\n\npsc:\n  alloydb-psc-ew1:\n    region: $locations:ew1\n    subnet_self_link: $subnets:prod/europe-west1/prod-default-ew1\n    address: 10.100.1.50\n    service_attachment:\n      psc_service_attachment_link: projects/producer-prj/regions/europe-west1/serviceAttachments/alloydb-sa\n```\n\n### 2.5 Cloud NGFW Enterprise Rule in `firewall-policies/*.yaml`\n```yaml\n# Inside firewall-policies/networking-policy.yaml\ningress_rules:\n  inspect-east-west:\n    priority: 1000\n    action: apply_security_profile_group\n    security_profile_group: $security_profile_groups:ips-profile-group\n    description: Delegate L7 IPS inspection to Cloud NGFW Enterprise\n    match:\n      source_ranges:\n        - $cidr_ranges_sets:rfc1918\n      destination_ranges:\n        - $cidr_ranges_sets:rfc1918\n      layer4_configs:\n        - protocol: all\n```\n\n---\n\n## 3. Handoff Outputs Contract for Downstream Stages\n\nWhen `2-networking` runs `terraform apply`, it emits stage outputs (`host_project_ids`, `vpc_self_links`, `subnet_self_links`, `subnet_proxy_only_self_links`, `subnet_psc_self_links`, and `2-networking.auto.tfvars.json`) consumed by `2-project-factory` and workload stages.\n\nAlways include a **Downstream Stage Handoffs** table in the generated dataset's `README.md` listing:\n1. Which **Shared VPC Host Projects** (`$project_ids:<key>`) and **Subnets** (`<vpc>/<region>/<subnet>`) downstream service projects should attach to in `2-project-factory`.\n2. Which **`proxy_only`**, **`psc`**, **`psa_configs`**, or **`network_attachments`** resources were pre-created in `2-networking` and which workload stage / CFF module (`gke-cluster-*`, `net-lb-app-*`, `cloudsql-*`) will consume them.\n3. Any external context variables (`var.security_profile_groups`, `var.folder_ids`, `var.kms_keys`, `var.perimeters`) that must be passed into `2-networking.auto.tfvars` when running `terraform apply`.\n\n<!-- File: skills/fast/networking/references/4-dataset-schema-and-tokens.md (Tokens: ~4265 | Category: anthropic-skill) -->\n# Phase 2 & 3 Reference: Dataset Directory Layout, Schemas & `$token` Rules\n\nThis reference governs **Phase 2 (Topology, IPAM & Plumbing Proposal)** and **Phase 3 (Dataset Generation & Validation)**. Consult the live JSON schemas in `fast/stages/2-networking/schemas/*.schema.json` alongside this guide when generating or modifying YAML files.\n\n---\n\n## 1. Canonical Dataset Directory Hierarchy & Schema Mapping\n\nEvery YAML file in a `2-networking` dataset must sit at one of the canonical factory paths below and begin with a `# yaml-language-server: $schema=...` header pointing to the corresponding schema in `fast/stages/2-networking/schemas/`:\n\n| Dataset-Relative Path | JSON Schema (`fast/stages/2-networking/schemas/`) | Factory & Purpose |\n| :--- | :--- | :--- |\n| `defaults.yaml` | `defaults.schema.json` | Stage defaults (`vpcs.defaults`, `projects.defaults`), `output_files`, and `context` maps (`locations`, `cidr_ranges`, `cidr_ranges_sets`, `email_addresses`, `iam_principals`, etc.). |\n| `folders/**/.config.yaml` | `folder.schema.json` | Optional folder hierarchy definitions (up to 4 levels) under the networking branch. |\n| `projects/**/*.yaml` or `folders/**/*.yaml` *(except `.config.yaml`)* | `project.schema.json` | Networking host/transit project definitions (`name`, `parent`, `services`, `shared_vpc_host_config`, `project_reuse`). Projects placed under `folders/<dir>/<proj>.yaml` automatically inherit `parent: $folder_ids:<dir>`. |\n| `ncc-hubs/*.yaml` | `ncc-hub.schema.json` | NCC Hub (`name`, `project_id`, `preset_topology: MESH\\|STAR`, `export_psc`, `groups`). |\n| `nvas/*.yaml` | `nva.schema.json` | Multi-NIC NVA clusters (`auto_instance_config.nics`, `instance_groups`, `ilb_config`, `ncc_ra_config`, `bgp_config`). |\n| `firewall-policies/**/*.yaml` | `firewall-policy.schema.json` | Hierarchical Firewall Policies (`parent_id: $folder_ids:<key>`) and Global/Regional Network Firewall Policies (`parent_id: $project_ids:<key>`, `region: global` or `$locations:<key>`, `attachments: { <vpc>: $networks:<vpc> }`). |\n| `dns/zones/**/*.yaml` | `dns.schema.json` | Cloud DNS zones (`private`, `forwarding`, `peering`, `public`, `recordsets`, `delegations`). |\n| `dns/response-policies/**/*.yaml` | `dns-response-policy-rules.schema.json` | Cloud DNS Response Policy zones and rules (`local_data`, `behavior: bypassResponsePolicy`). |\n| `vpcs/<vpc-key>/.config.yaml` | `vpc.schema.json` | VPC configuration (`name`, `project_id`, `mtu`, `routing_mode`, `nat_config`, `ncc_config`, `peering_config`, `psa_configs`, `network_attachments`, `routers`, `routes`, `policy_based_routes`, `dns_policy`, `factories_config`). |\n| `vpcs/<vpc-key>/addresses.yaml` | `addresses.schema.json` | Reserved addresses in `<vpc-key>` (`internal`, `external`, `global`, `psc`, `psa`). |\n| `vpcs/<vpc-key>/firewall-rules/**/*.yaml` | `firewall-rules.schema.json` | VPC-level `ingress` and `egress` firewall rules. |\n| `vpcs/<vpc-key>/subnets/**/*.yaml` | `subnet.schema.json` | Subnet definitions (`name`, `region`, `ip_cidr_range`, `secondary_ip_ranges`, `proxy_only`, `psc`, `private_nat`, `iam`). |\n| `vpcs/<vpc-key>/vpns/**/*.yaml` | `vpn.schema.json` | HA VPN gateways (`name`, `region`, `peer_gateways`, `router_config`, `tunnels`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/vlan-attachments/**/*.yaml` | `vlan-attachments.schema.json` | Dedicated/Partner Interconnect VLAN attachments (`name`, `region`, `peer_asn`, `router_config`, `dedicated_config` / `partner_config`, `ncc_spoke_config`). |\n| `vpcs/<vpc-key>/attachment-groups/**/*.yaml` | `attachment-groups.schema.json` | Interconnect attachment groups for SLA redundancy. |\n\n---\n\n## 2. Symbolic Context (`$token`) Namespace & Key Derivation Rules\n\nFAST `2-networking` resolves `$namespace:key` tokens across factories. Never hardcode GCP resource self-links when a context token exists.\n\n### 2.1 Resources Created Inside the Dataset\nPay close attention to whether a token key comes from the **directory/file stem** or the **`name:` attribute** inside the YAML file:\n\n| Token Syntax | Key Derivation Rule | Resolved Value & Where Used |\n| :--- | :--- | :--- |\n| `$project_ids:<project-key>` | **File stem** of `projects/**/<project-key>.yaml` or `folders/**/<project-key>.yaml` (e.g., `projects/net-core-0.yaml` $\\rightarrow$ `$project_ids:net-core-0`). | Project ID. Used in `project_id:` across VPCs, DNS zones, NCC hubs, NVAs, and `ncc-hubs/*.yaml` `auto_accept`. |\n| `$folder_ids:<folder-key>` | **Relative directory path** under `folders/` (or passed from Stage 0 `var.folder_ids`, e.g., `$folder_ids:networking`). | Folder ID (`folders/123456`). Used in `projects/*.yaml` `parent:` and `firewall-policies/*.yaml` `parent_id:` / `attachments:`. |\n| `$networks:<vpc-key>` | **Directory path** under `vpcs/<vpc-key>/.config.yaml` (e.g., `vpcs/prod/.config.yaml` with `name: prod-0` is referenced as **`$networks:prod`**, NOT `prod-0`). | VPC self-link (or name in VPNs). Used in `peering_config`, `dns/zones/*.yaml` `client_networks` & `peer_network`, `firewall-policies/*.yaml` `attachments`, and `nvas/*.yaml` `nics[].network`, `ilb_config.forwarding_rules[].network`, & `ncc_ra_config.spokes[].network`. |\n| `$subnets:<vpc-key>/<region>/<subnet-name>` | `<vpc-key>` (directory under `vpcs/`) + **resolved region name** (e.g. `europe-west1`, NOT the `$locations:` alias) + **`name:` attribute** inside the subnet YAML. *(Resolves regular and `private_nat` subnets only, not `proxy_only` or `psc` subnets).* | Subnet self-link. Used in `addresses.yaml` (`subnetwork` / `subnet_self_link`), `nvas/*.yaml` (`nics[].subnet`), Cloud NAT `subnetworks`, and Cloud Router `interfaces[].subnet`. |\n| `$ncc_hubs:<hub-name>` | Required **`name:` attribute** inside `ncc-hubs/<file>.yaml`. | NCC Hub ID. Used in `vpcs/<vpc>/.config.yaml` `ncc_config.hub`, `vpns`/`vlan-attachments` `ncc_spoke_config.hub`, and `nvas/*.yaml` `ncc_ra_config.spokes[].hub`. |\n| `$ncc_groups:<hub-name>/<group-key>` | `<hub-name>` (`name:` in `ncc-hubs/*.yaml`) + map key under `groups:` (e.g., `$ncc_groups:hub/default` or `$ncc_groups:transit/center`). | NCC Group ID. Used in `ncc_config.group`, `ncc_spoke_config.group`, and `ncc_ra_config.spokes[].group`. |\n| `$routers:<vpc-key>/<router-key>` | `<vpc-key>` (directory under `vpcs/`) + map key under `routers:` in `vpcs/<vpc-key>/.config.yaml`. | Cloud Router name. Used in `vpns/*.yaml` and `vlan-attachments/*.yaml` under `router_config.name` (with `create: false`). |\n| `$vpn_gateways:<vpc-key>/<vpn-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vpns/<file>.yaml` (e.g., `vpcs/hub/vpns/onprem.yaml` with `name: to-onprem` $\\rightarrow$ `$vpn_gateways:hub/to-onprem`). | HA VPN Gateway ID. Used in `peer_gateways.<key>.gcp` for GCP-to-GCP HA VPNs. |\n| `$vlan_attachments:<vpc-key>/<att-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/vlan-attachments/<file>.yaml`. | VLAN Attachment ID. Used in `vpcs/<vpc-key>/attachment-groups/*.yaml` `attachments:`. |\n| `$attachment_groups:<vpc-key>/<grp-name>` | `<vpc-key>` + required **`name:` attribute** inside `vpcs/<vpc-key>/attachment-groups/<file>.yaml`. | Interconnect Attachment Group ID. Used in `vpcs/<vpc-key>/vlan-attachments/*.yaml` `attachment_group:`. |\n| `$addresses:<vpc-key>/<addr-key>` | `<vpc-key>` + map key under `internal:` in `vpcs/<vpc-key>/addresses.yaml` (e.g., `internal: { app-ilb: ... }` in `vpcs/prod/addresses.yaml` $\\rightarrow$ **`$addresses:prod/app-ilb`**, with NO `/internal/` segment). | Reserved internal IP address. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop`) and `policy_based_routes` (`next_hop_ilb_ip`). |\n| `$addresses:<vpc-key>/<nva-name>` | `<vpc-key>` (from `ilb_config.forwarding_rules[].network`) + **`name:` attribute** of the NVA in `nvas/<file>.yaml`. | NVA Internal Passthrough NLB VIP in `<vpc-key>`. Used in `vpcs/<vpc-key>/.config.yaml` `routes` (`next_hop_type: ilb`, `next_hop:`) and `policy_based_routes` (`next_hop_ilb_ip:`). |\n| `$nva_instances:<nva-name>-<zone>-<index>` | `<nva-name>` (`name:` in `nvas/*.yaml`) + `<zone>` key in `instance_groups:` + `0`-based instance index (for `auto_create_instances > 0`, e.g., `$nva_instances:primary-b-0`). | NVA VM instance self-link. Used in explicit Cloud Router `peers[].router_appliance_instance` (`vpcs/<vpc>/.config.yaml`) and `ncc_ra_config.spokes[].instances[].vm`. |\n| `$nva_addresses:<nva-name>-<zone>-<index>/<vpc-key>` | Instance key (`<nva-name>-<zone>-<index>`) + `<vpc-key>` of the attached NIC (e.g., `$nva_addresses:primary-b-0/routing`). | NVA VM NIC internal IP in `<vpc-key>`. Used in explicit Cloud Router `peers[].address` and `ncc_ra_config.spokes[].instances[].ip`. |\n\n### 2.2 Stage Context & External Variables (`defaults.yaml` & `var.context`)\n- **`$locations:<key>`:** Defined under `context.locations` in `defaults.yaml` (e.g., `ew1: europe-west1`, `ew8: europe-west8`). Supported across subnets, routers, Cloud NAT, NVAs, VPNs, VLAN attachments, firewall policies, addresses, routes, and `policy_based_routes`.\n- **`$cidr_ranges:<key>` & `$cidr_ranges_sets:<key>`:** Defined under `context.cidr_ranges` and `context.cidr_ranges_sets` in `defaults.yaml`.\n  - `$cidr_ranges_sets:<key>` is resolved in **both** `firewall-policies/*.yaml` and VPC `vpcs/<vpc>/firewall-rules/*.yaml`.\n  - `$cidr_ranges:<key>` is resolved in `vpcs/<vpc>/.config.yaml` `routes` and `policy_based_routes`, as well as subnet configs and `firewall-rules/*.yaml`.\n- **`$custom_roles:<key>`, `$iam_principals:<key>`, `$email_addresses:<key>`, `$kms_keys:<key>`, `$tag_keys:<key>`, `$tag_values:<key>`, `$vpc_sc_perimeters:<key>`:** Defined in `defaults.yaml` `context` (except `folder_ids`, which comes from `var.folder_ids` or `var.context.folder_ids`) or passed from root stage variables (`var.custom_roles`, `var.iam_principals`, `var.service_accounts`, `var.kms_keys`, `var.tag_keys`, `var.tag_values`, `var.perimeters`).\n- **`$security_profile_groups:<key>`:** Passed via `var.security_profile_groups` (or `defaults.yaml` `context.security_profile_groups`). Used in `firewall-policies/*.yaml` rules with `action: apply_security_profile_group`.\n\n### 2.3 Common Schema & Factory Gotchas\n- **Unique Subnet Filename Stems per VPC (`vpcs/<vpc>/subnets/`):** `modules/net-vpc` keys raw subnet YAML files by `basename(file)` (stripping any `<region>/` subdirectory). Never create two subnet files with the same filename in different regional folders of the same VPC (e.g., `subnets/ew1/default.yaml` and `subnets/ew8/default.yaml` will collide); always include the region or tier in the filename (`subnets/ew1/prod-default-ew1.yaml`).\n- **Subnet `secondary_ip_ranges` (`subnet.schema.json`):** Each secondary range entry under `secondary_ip_ranges:` must be an **object** with `ip_cidr_range` (or `reserved_internal_range`), never a bare CIDR string:\n  ```yaml\n  secondary_ip_ranges:\n    pods:\n      ip_cidr_range: 10.104.0.0/17\n    services:\n      ip_cidr_range: 10.108.0.0/21\n  ```\n- **VPC `network_attachments` (`vpc.schema.json`):** Under `network_attachments.<key>`, `subnet` must be `<resolved-region>/<subnet-name>` (e.g., `europe-west1/prod-psc-int-ew1`), the connection toggle is `automatic_connection: true|false` (not `automatic_accept`), and `producer_accept_lists` / `producer_reject_lists` take literal project IDs (they do not interpolate `$project_ids:`).\n- **Disabling VLAN Attachments (`vpcs/<vpc>/.config.yaml`):** To disable loading `vlan-attachments/` for a VPC (such as in the reference datasets), set `factories_config: { vlan_attachments: \"none\" }` inside `vpcs/<vpc>/.config.yaml`—there is no `vlan_attachments` key under `var.factories_config.paths` in `.tfvars`.\n- **Firewall Policies (`firewall-policies/*.yaml`) vs. VPC Firewall Rules (`vpcs/<vpc>/firewall-rules/*.yaml`):**\n  - `firewall-policies/*.yaml` (`firewall-policy.schema.json`) uses `ingress_rules` / `egress_rules`, `action: allow|deny|goto_next|apply_security_profile_group`, `match.source_ranges`, `match.destination_ranges`, and `match.layer4_configs: [{ protocol: tcp, ports: [\"22\"] }]`.\n  - `vpcs/<vpc>/firewall-rules/*.yaml` (`firewall-rules.schema.json`) uses `ingress` / `egress` maps where each rule defines top-level `source_ranges` / `destination_ranges`, `rules: [{ protocol: tcp, ports: [\"22\"] }]` (with `deny: false` for allow or `deny: true` for deny), and `enable_logging: { include_metadata: false }` (an object, not a boolean).\n\n---\n\n## 3. Generating `2-networking.auto.tfvars`\n\nAlways generate a companion `2-networking.auto.tfvars` file next to (or pointing to) the custom dataset directory:\n\n### 3.1 When Deployed on Top of FAST `0-org-setup`\n`0-globals.auto.tfvars.json` and `0-org-setup.auto.tfvars.json` already supply `billing_account`, `organization`, `prefix`, `folder_ids`, `service_accounts`, and `storage_buckets`. The `.tfvars` file only needs `factories_config`:\n\n```hcl\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n### 3.2 When Deployed Standalone (Without `0-org-setup`)\nProvide the required FAST root variables (`billing_account`, `organization`, `prefix`) and any referenced `$folder_ids` or `$project_ids` (if reusing existing projects). In `defaults.yaml`, either omit `output_files.storage_bucket` (or use `output_files.local_path`) or pass `storage_buckets` in `.tfvars` so `apply` does not attempt to write to an unresolved `$storage_buckets:iac-0/iac-outputs` string:\n\n```hcl\nbilling_account = {\n  id = \"012345-6789AB-CDEF01\"\n}\n\norganization = {\n  domain      = \"example.com\"\n  id          = 123456789012\n  customer_id = \"C01234567\"\n}\n\nprefix = \"myorg\"\n\nfolder_ids = {\n  networking = \"folders/1234567890\"\n}\n\nfactories_config = {\n  dataset = \"/absolute/or/relative/path/to/custom-dataset\"\n}\n```\n\n---\n\n## 4. Phase 3 Execution: Validation & `terraform plan`\n\nAfter writing the YAML files into the user's custom dataset directory:\n\n1. **Run the Bundled Validator Script (Mandatory Gate):**\n   ```bash\n   uv run skills/fast/networking/scripts/validate_dataset.py <path/to/custom-dataset>\n   ```\n   - This script validates:\n     1. Every YAML file against its JSON schema in `fast/stages/2-networking/schemas/`.\n     2. All `$token` references, strict CIDR syntax (no host bits set), intra-VPC and cross-VPC CIDR overlaps, unique subnet file basenames per VPC, reciprocal VPC peerings, unique firewall rule names per project, and NVA (`ilb_config` / `ncc_ra_config` / BGP / `routes` vs. `advertise`) guardrails.\n     3. Isolated `terraform init -backend=false`, `terraform validate`, and `terraform plan -refresh=false` in a temporary directory using synthetic FAST variables.\n   - Fix any reported errors or unintended warnings and re-run until `validate_dataset.py` exits with code `0`.\n\n2. **Write `<custom-dataset>/README.md`:**\n   Include a concise `README.md` in the custom dataset folder summarizing:\n   - Topology overview and regional IPAM & VPC summary table.\n   - **Downstream Stage Handoffs** (any requested ALBs, Cloud NGFW Enterprise security profile groups, GKE secondary ranges, PSC service attachments, or PSA databases that follow-up stages will deploy on top of this plumbing).\n\n<!-- File: skills/fast/networking/SKILL.md (Tokens: ~2023 | Category: anthropic-skill) -->\n---\nname: fast-networking\ndescription: Designs, customizes, and validates Cloud Foundation Fabric FAST Stage 2 (2-networking) YAML datasets from user requirements, reference diagrams, or existing topologies. Use when a user asks to design a GCP landing zone network, choose or customize a 2-networking dataset (Single Shared VPC, NCC, NVA NCC Router Appliance, NVA ILB sandwich, VPC Peering, HA VPN), convert a network diagram into FAST YAML, or validate a 2-networking dataset.\n---\n\n# FAST `2-networking` Dataset Designer\n\nThis skill guides users from functional networking and security requirements—or an attached reference diagram/IP table—to a complete, schema-validated Cloud Foundation Fabric FAST Stage 2 (`fast/stages/2-networking`) YAML dataset in a custom directory.\n\n## Core Principles & Execution Rules\n\n> [!IMPORTANT]\n> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):**\n> For all design and customization tasks, prepend the following 3-phase progress block to **every** response so the user always sees the current phase and active step (if the user only asks to validate an existing dataset on disk, run `scripts/validate_dataset.py` directly and report the results):\n> ```text\n> FAST 2-Networking Designer Progress:\n> - Phase 1: Requirements Discovery\n>   (Step 1/2: Baseline & VPC Footprint - IN PROGRESS)\n> - Phase 2: Topology, IPAM & Plumbing Proposal\n>   (Not started)\n> - Phase 3: Dataset Generation & Validation\n>   (Not started)\n> ```\n\n> [!IMPORTANT]\n> **Understanding Turn Boundaries & Conditional Questioning (`ask_user` / `ask_question` / `question`):**\n> - Once you ask the user a question or present a specification for confirmation, **stop execution immediately (do not call more tools)**.\n> - Never simulate, guess, or assume the user's answer in the same turn. Wait for the user's next message.\n> - **NEVER ask a meta-question about how the user wants to start:** Never ask whether the user wants an interview vs. diagram import vs. cloning a dataset. If the user did not attach a diagram or name an existing dataset in their prompt, start **Turn 1** of the interview immediately (you may mention in one line of prose that they can share a diagram or dataset name at any time).\n> - **NEVER bundle dependent questions in the same turn or question tool call:** Only mutually **independent** questions may be asked together. You **MUST** resolve whether the user wants a **Single Shared VPC** vs. **Multi-VPC Hub & Spokes** *before* asking any questions about spoke-to-spoke routing, NCC hubs, peering, or inter-VPC transit NVAs.\n> - **Concrete Options Only:** Every multiple-choice option must be self-contained and actionable. Never offer a vague option like *\"Single region (e.g., europe-west8 or europe-west1)\"* that forces an extra follow-up turn just to ask *which* single region they meant; list the specific common region choices directly and allow custom free-text input.\n\n1. **Step-by-Step by Default, Adaptive on Rich Input:**\n   - **Default Mode (Conditional Interview DAG):** When the user starts with a general request (e.g., *\"Help me define my FAST networking dataset\"*), follow the Conditional Interview DAG in `references/1-requirements-to-topology.md`, asking only the questions that apply to the user's chosen branch (`2` turns for Single Shared VPC; `3` turns for Multi-VPC Hub & Spokes) and recommending sensible FAST defaults.\n   - **Adaptive Mode (Diagram, IP Table, or Detailed Spec Provided):** When the user provides an architecture diagram (PNG/SVG/PDF/Draw.io), an IP table, or a detailed prompt upfront, extract all stated facts immediately, mark **Phase 1** completed, jump straight to **Phase 2 (Topology, IPAM & Plumbing Proposal)** with pre-filled defaults for any unspecified CIDRs/ASNs, and ask for a single sign-off before generating files.\n   - **Validation-Only Mode:** When the user points to an existing dataset directory and asks to validate or test it, run `uv run skills/fast/networking/scripts/validate_dataset.py <path>` directly and report the findings.\n   - **Zero-Redundancy Rule:** Never ask the user to repeat a region, CIDR, environment name, connectivity choice, or workload type they already provided in their prompt or diagram.\n\n2. **Stage 2 Boundary (\"Plumbing vs. Downstream Consumption\"):**\n   - `fast/stages/2-networking` provisions **foundational network plumbing only**: networking host projects, VPCs, subnets (primary, secondary, `proxy_only`, `psc`, `private_nat`), `psa_configs`, reserved addresses, Cloud Routers, Cloud NAT, VPC Peering, NCC hubs/spokes, HA VPNs, Interconnect VLAN attachments, NVA instances/ILBs/BGP spokes, Cloud DNS zones/response policies, and hierarchical/network/VPC firewall policies.\n   - Higher-level security and application resources—such as **Cloud NGFW Enterprise** (`firewall_endpoint` and `security_profile_group`, typically managed in a `2-networking-ngfw` or security stage), **Application Load Balancers** (`net-lb-app-*`), **GKE clusters**, **Cloud SQL / AlloyDB / NetApp instances**, and **PSC Producer `ServiceAttachment`s**—are deployed in **follow-up stages** (`2-project-factory` or workload/security stages).\n   - Whenever the user requests one of these capabilities, configure the required Stage 2 plumbing (e.g., `/23` `proxy_only: true` subnets, `psc: true` subnets, `psa_configs`, `$security_profile_groups:` rules in `firewall-policies/`) and record the downstream handoff in the generated dataset's `README.md`. Never invent unsupported YAML keys inside `2-networking`.\n\n3. **Custom Dataset Output Target:**\n   - Never overwrite the in-tree reference datasets under `fast/stages/2-networking/datasets/` unless explicitly developing/updating Fabric itself.\n   - Always generate the user's dataset into a dedicated custom directory (e.g., `./fast-config/2-networking/data/` or a user-specified path) along with a ready-to-use `2-networking.auto.tfvars` file that maps `factories_config` to that directory.\n\n4. **Mandatory Mechanical Validation Gate:**\n   - Never declare a generated or modified dataset complete until you have run `scripts/validate_dataset.py` against the target directory (checking JSON schemas, `$token` reference integrity, intra-VPC and cross-VPC CIDR overlaps, and routing/security guardrails, plus `terraform validate` and `terraform plan` when Terraform is available).\n\n---\n\n## Workflow Map\n\nBefore executing a phase, read its corresponding reference document for the decision tables, YAML patterns, and guardrails.\n\n### Phase 1: Requirements Discovery\n*Description:* Walk the Conditional Interview DAG (or extract facts from an attached diagram/prompt), forking cleanly between **Single Shared VPC** and **Multi-VPC Hub & Spokes**.\\\n*Reference: [Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- **Step 1:** Stage 0 Integration, Concrete Target Regions & Primary VPC Footprint Fork (`Single Shared VPC` vs. `Multi-VPC Hub & Spokes`)\n- **Step 2:** Branch-Specific Requirements Discovery (`Step 2` Single-VPC Plumbing on Path A; `Step 2a` Spoke Scope/Inspection + `Step 2b` Hybrid/Egress/Plumbing on Path B)\n\n### Phase 2: Topology, IPAM & Plumbing Proposal (Single Sign-Off Gate)\n*Description:* Map the functional requirements to a **Single Shared VPC** layout or one of the canonical `2-networking` hub-and-spoke topologies (`ncc`, `nva-ncc`, `nva`, `peerings`, `vpns`), pre-fill sensible non-overlapping default CIDRs, BGP ASNs, DNS zones, and Stage 2 plumbing in a single unified blueprint table, and ask the user once to confirm (or tweak) the proposal and output directory.\\\n*References:*\n- *[Requirements to Topology Mapping](references/1-requirements-to-topology.md)*\n- *[Routing and Security Mechanisms](references/2-routing-and-security-mechanisms.md)*\n- *[Stage Boundaries and Plumbing](references/3-stage-boundaries-and-plumbing.md)*\n- *[Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 3:** Unified Topology, Pre-Filled Regional IPAM/BGP/DNS Defaults, Output Path & Downstream Handoff Sign-Off\n\n### Phase 3: Dataset Generation & Validation\n*Description:* Generate the YAML dataset and `2-networking.auto.tfvars` in the user's custom output directory, run `scripts/validate_dataset.py` (`terraform validate` and `terraform plan`), and write the dataset `README.md`.\\\n*Reference: [Dataset Schema and Tokens](references/4-dataset-schema-and-tokens.md)*\n- **Step 4:** YAML Dataset & `2-networking.auto.tfvars` Generation\n- **Step 5:** Mechanical Validation (`scripts/validate_dataset.py`, including `terraform validate` and `terraform plan`)\n- **Step 6:** Dataset `README.md` & Follow-up Stage Handoff Summary\n\n<!-- File: skills/fast/prerequisites/references/phase1-env-and-auth.md (Tokens: ~1052 | Category: anthropic-skill) -->\n# Phase 1: Environment & Authentication\n\n### Step 1: Environment Assessment & Initialization\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your very first Turn 1 response prepends the progress block showing:\n>   - Phase 1: Environment & Authentication (Step 1/2: Target Environment Selection - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n>\n> **MANDATORY START POINT (TURN 1):** \n> - You MUST begin the entire workflow by asking the user to clarify their target environment (**Standard GCP** or **Google Cloud Dedicated (GCD)**). \n> - You **MUST NOT** run any `gcloud` commands, check active credentials, or proceed to Step 2 or any other Phase/Step in Turn 1. \n> - You **MUST** stop execution immediately after asking this question and wait for the user's response.\n> - Do NOT assume, pre-fill, or guess the environment based on local config files, active credentials, or background command success.\n>\n> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) in a subsequent turn and wait for their response.\n\n1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.**\n2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.**\n3. *If GCD is selected*, ask the user if they are working in one of the known universes: **S3NS (France)** or **Berlin (Germany)**.\n   - *If S3NS:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.s3nscloud.fr`\n     - Universe API Domain: `s3nsapis.fr`\n     - Universe Name: `s3ns`\n     - Universe Prefix: `s3ns`\n     - Universe Region: `u-france-east1`\n   - *If Berlin:* Pre-fill the following values:\n     - Universe Web Domain: `cloud.berlin-build0.goog`\n     - Universe API Domain: `apis-berlin-build0.goog`\n     - Universe Name: `berlin`\n     - Universe Prefix: `eu0`\n     - Universe Region: `u-germany-northeast1`\n   - *If neither (Custom):* Gather the 5 universe-specific details manually from the user.\n   - *Action:* Present the final list of the 5 universe values to the user for review. Ask for explicit confirmation and offer them the opportunity to change any of the values before proceeding.\n\n### Step 2: Authentication\n\n> [!CRITICAL]\n> **DO NOT START STEP 2 PREMATURELY:** You MUST NOT check authentication, run `gcloud config list`, or execute Step 2 until Step 1 (Environment Assessment & Initialization) has been completely finished and confirmed by the user in previous turns.\n\n1. Ask the user if they are already authenticated with Google Cloud using the correct principal.\n   - *If yes:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use.\n     - *If they confirm (and Step 1 is already completed):* Proceed directly to Phase 2 (Step 3).\n     - *If they do not confirm:* Proceed with the authentication steps below.\n   - *If no:* Proceed with the authentication steps below.\n2. *Standard GCP:* Provide or execute the command:\n   ```bash\n   gcloud auth login\n   gcloud auth application-default login\n   ```\n3. *GCD:* Automate or guide the user through WIF login. Ask for the workforce pool audience string first, then generate the configuration:\n   ```bash\n   # (Use the gathered GCD variables to fill placeholders)\n   gcloud config configurations create <UNIVERSE_NAME>\n   gcloud config configurations activate <UNIVERSE_NAME>\n   gcloud config set universe_domain <UNIVERSE_API_DOMAIN>\n\n   gcloud iam workforce-pools create-login-config <AUDIENCE> \\\n     --universe-cloud-web-domain=\"<UNIVERSE_WEB_DOMAIN>\" \\\n     --universe-domain=\"<UNIVERSE_API_DOMAIN>\" \\\n     --output-file=\"/tmp/wif-login-config-<UNIVERSE_NAME>.json\" \\\n     --activate\n\n   gcloud auth login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json --no-launch-browser\n   gcloud auth application-default login --login-config=/tmp/wif-login-config-<UNIVERSE_NAME>.json\n   ```\n4. Explicitly ask the user to confirm they have successfully authenticated before moving to the next phase.\n\n<!-- File: skills/fast/prerequisites/references/phase2-admin-and-baseline.md (Tokens: ~1307 | Category: anthropic-skill) -->\n# Phase 2: Admin Principal & Baseline Info\n\n> [!CRITICAL]\n> **MANDATORY PROGRESS BLOCK REMINDER:**\n> - Ensure your responses in Phase 2 prepend the progress block showing:\n>   - Phase 1: Environment & Authentication (2/2 steps completed)\n>   - Phase 2: Admin Principal & Baseline Info (Step 1/2: Admin Principal Definition - IN PROGRESS)\n>   - All subsequent phases: (Not started)\n\n### Step 3: Admin Principal Definition\n\n1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter.\n2. Determine the Admin Principal approach by asking the user to choose between two options:\n   - **Approach A (Preferred): Use a pre-created Group.**\n     - *Action:* Explain that using a group (e.g., `group:gcp-organization-admins@example.com`) is the standard and preferred way. **Crucially, clarify that the group provided MUST be a group that the user's current authenticated identity belongs to**, otherwise they will lock themselves out.\n     - *Action:* Ask the user to provide the group email address.\n     - *Action:* Explicitly ask the user to confirm that their current identity (the one they just authenticated with) is already a member of this group.\n     - *Action:* If the user answers \"No\" to the membership confirmation, **DO NOT PROCEED**. Inform the user that proceeding will lock them out. Ask them to either authenticate with an identity that *is* a member of the group (and restart the authentication step), or provide a different group that their current identity belongs to.\n   - **Approach B (Fallback): Use a Single User.**\n     - *Action:* Explain that this flow uses a single user as the sole GCP Org Admin, but more can be added later.\n     - *Action:* Run (or ask the user to run) `gcloud config list account --format=\"value(core.account)\"` to retrieve their current authenticated principal.\n     - *Action:* Show the user their current principal and explicitly ask them to confirm this is the identity they want to use as the Admin Principal.\n\n### Step 4: Baseline Information Gathering\n\n1. Gather baseline information required for `0-org-setup`:\n   - Organization ID (and the associated Directory Customer ID, which is important for Standard GCP but not required for GCD)\n   - Billing Account ID (Mandatory for subsequent stages, even if not required for the GCD temporary project)\n   *Action:* When prompting the user for the Organization ID and Billing Account ID, explicitly instruct them in the prompt/question that they can leave the field blank (or type \"list\") to have you automatically run the relevant `gcloud` command (`gcloud organizations list --format=\"json\"` or `gcloud beta billing accounts list`). Also, instruct them that they can type a keyword to filter the list.\n   *Action:* If the user leaves the field blank or types \"list\", run the `gcloud` command without filters. If the user types a keyword that is not a valid Organization ID (numeric) or Billing Account ID (e.g., `012345-6789AB-CDEF01`), run the `gcloud` command and use that keyword to filter the results using a **case-insensitive** regex match. Ensure the regex pattern is enclosed in single quotes within the filter argument (e.g., `--filter=\"displayName~'(?i)KEYWORD'\"`). Do NOT use `*` wildcards in the filter.\n   *Action:* If the filtered `gcloud` command returns no results, inform the user and ask if they want to provide a different keyword or fetch all items (run without a filter).\n   *Action:* Once you have results for Organizations (filtered or unfiltered), extract the Organization ID, the Display Name (domain), and the Directory Customer ID (found in `owner.directoryCustomerId` in the JSON output). Sort them alphabetically by the display name, and then present the results for the user to make their selection. Note the Organization ID, Domain, and Customer ID for Phase 4. For Billing Accounts, do the same sorting and selection.\n2. Determine the **Admin Principal's** access level to the provided Billing Account ID. Ask which of the following three scenarios applies to the Admin Principal (not necessarily the current user):\n   - **Scenario 1 (Billing Administrator):** The Admin Principal has `roles/billing.admin`.\n     - *Action:* Ask a follow-up question: \"Is your billing account managed by the same organization where we are installing FAST, or outside of it? (You can check this in the Google Cloud Console by going to Billing -> using the organization picker on top -> checking if the account is listed under this organization).\"\n     - *If Inside the Org:* Note that `roles/billing.admin` WILL be assigned at the Organization level in Step 6. Instruct the user that we will deactivate the billing factories path for now, but if account-level IAM also needs to be managed via FAST later, they can reactivate the path and use the billing YAML to do it.\n     - *If Outside the Org:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6.\n   - **Scenario 2 (Billing User):** The Admin Principal has `roles/billing.user` but NOT admin rights.\n     - *Action:* Note that `roles/billing.admin` WILL NOT be assigned at the Organization level in Step 6. Either disable the billing YAML via the `factories_config` variable or comment it out, since the Admin Principal cannot control IAM on the account.\n     - *Action:* **Explain to the user:** The service accounts for IaC (and therefore the provider switch and subsequent stages, except for VPC-SC) will not be operative until the correct billing permissions have been assigned to them outside of FAST.\n   - **Scenario 3 (No Access):** The Admin Principal has absolutely no rights on the billing account.\n     - *Action:* **Clearly state:** This scenario is mostly used for development purposes, is strongly discouraged, and requires advanced Terraform skills and FAST knowledge to proceed.\n\n<!-- File: skills/fast/prerequisites/references/phase3-bootstrap-and-iam.md (Tokens: ~755 | Category: anthropic-skill) -->\n# Phase 3: Bootstrap Project & IAM\n\n### Step 5: IAM Role Assignments\n\n1. Grant the following roles to the chosen Admin Principal at the Organization level. **CRITICAL:** Only include `roles/billing.admin` in this list if the user selected Scenario 1 (Billing Administrator) AND confirmed the billing account is managed Inside the Organization in Step 4.\n   **CRITICAL EXECUTION RULE:** Do NOT create temporary bash scripts (e.g., `assign_roles.sh`) to execute this loop. You MUST execute the `for` loop inline directly using the `run_shell_command` tool, or output the exact inline loop for the user to copy/paste.\n   ```bash\n   # Roles to assign:\n   # [roles/billing.admin] <-- CONDITIONAL (See above)\n   # roles/logging.admin\n   # roles/iam.organizationRoleAdmin\n   # roles/orgpolicy.policyAdmin\n   # roles/resourcemanager.folderAdmin\n   # roles/resourcemanager.organizationAdmin\n   # roles/resourcemanager.projectCreator\n   # roles/resourcemanager.tagAdmin\n   # roles/owner\n\n   # Loop example for the user or tool execution:\n   for role in [ROLES_LIST]; do\n     gcloud organizations add-iam-policy-binding <ORG_ID> \\\n       --member=\"<ADMIN_PRINCIPAL>\" --role=\"$role\" --condition=None\n   done\n   ```\n\n### Step 6: Bootstrap Project Setup\n\n1. Explain that a temporary bootstrap project is required to track API quota before organization policies are fully established.\n2. Ask the user if they already have a suitable project they can use for this purpose.\n   - *If yes:* Ask if this project is already configured as the active project in `gcloud`. If the user does not know, run `gcloud config list project --format=\"value(core.project)\"` to check for them.\n     - If it is already configured, fetch the Project ID using `gcloud config list project --format=\"value(core.project)\"`. Explicitly ask the user to confirm if this fetched Project ID is the one they want to use. **Only if they answer \"No\" to this confirmation**, ask them to provide the correct Project ID.\n     - If it is not configured, ask the user to provide the Project ID.\n   - *If no:* Ask the user to use the Cloud Console to create a temporary project (must be linked to the billing account). Ask them to provide the new Project ID once created.\n3. Once the Project ID is provided or fetched, ensure it is set as the default project. If it is not already set, run:\n   ```bash\n   gcloud config set project <TEMP_PROJECT_ID>\n   ```\n4. Enable the required baseline APIs on the project:\n   - The required APIs are: `bigquery.googleapis.com`, `cloudbilling.googleapis.com`, `cloudresourcemanager.googleapis.com`, `essentialcontacts.googleapis.com`, `iam.googleapis.com`, `logging.googleapis.com`, `orgpolicy.googleapis.com`, `serviceusage.googleapis.com`.\n   - *If the project was pre-existing:* Ask the user if they want you to check which services are already enabled.\n     - *If yes:* Run `gcloud services list --enabled --format=\"value(config.name)\"` to get the current list. Compute the delta between the enabled services and the required list. Only run `gcloud services enable <MISSING_APIS>` for the ones that are missing.\n     - *If no:* Run the full `gcloud services enable` command for all required APIs.\n   - *If the project is new:* Run the full `gcloud services enable` command for all required APIs.\n\n<!-- File: skills/fast/prerequisites/references/phase4-config-and-wrapup.md (Tokens: ~2193 | Category: anthropic-skill) -->\n# Phase 4: Configuration & Wrap-up\n\n### Step 7: Configuration Generation\n\n1. **Explain Datasets:** Briefly explain to the user that FAST uses \"datasets\" (collections of YAML files) that fully describe the design, architecture, resources, and policies applied.\n2. **Select the Dataset:** Ask the user to select the dataset they want to use for their landing zone design.\n   - *If GCD:* Explicitly state that the `classic-gcd` dataset must be used for GCD installations.\n   - *If Standard GCP:* Offer the available datasets and ask them to choose one. **Crucially, only search for available datasets within the `fast/stages/0-org-setup/datasets/` directory.** Do not search across the entire repository or other FAST stages. Provide a one-line description below each dataset when presenting the options (e.g., \"classic: The standard FAST landing zone architecture\", \"hardened: A more restrictive, hardened landing zone architecture\").\n3. **Explain Defaults Configuration:** Explain to the user that we are starting the configuration of the `defaults.yaml` file, which drives the static configuration of the dataset by providing the Org ID, Billing ID, user-specified locations, and any static values they need to bring in from the outside (like additional IAM principals used in the YAML files to assign IAM roles).\n4. **Determine Locations:** FAST uses a set of locations for different services.\n   - *If GCD:* The region is fixed based on the universe selected in Phase 1. Set the `logging` location to `global` and all other required locations to the Universe Region. Do not ask the user to choose; simply show them the configured locations.\n   - *If Standard GCP:* Check the `fast/stages/0-org-setup/schemas/defaults.schema.json` to identify the required location keys (e.g., `bq`, `gcs`, `logging`, `pubsub`). First, ask the user to provide a \"base location\" (e.g., `europe-west1`), explaining that submitting without answering will confirm the default value. Even if the user confirms the base location, you must then ask if they need to override the location for any individual services.\n5. **Determine Local Path:** Explain to the user that FAST generates provider configurations and other files that need to be stored outside the repository. This is defined by the `output_files.local_path` setting. Propose a default path based on the chosen prefix (e.g., `~/fast-config/<prefix>`) and ask the user to confirm or provide a different path. **Crucially, if the user provides a relative path (e.g., `custom-fast-config` or `./custom-fast-config`), use it exactly as provided relative to the current working directory. Do not automatically prepend `~/` to their input.** Once confirmed, create this directory using `mkdir -p <LOCAL_PATH>`.\n6. **Ask for Additional Context:** Ask the user if there are any other static values they want to bring in from outside to be referenced in the YAML files. Show them the available context keys (excluding `condition_vars`). Provide examples showing that prefixes are mandatory for IAM principals (e.g., `user:foo@example.com`, `group:bar@example.com`). For GCD, also show a `principalSet:` example. **Do not use shell commands like `echo` or `cat` to show this list; output it directly in your chat message.**\n7. **Create Local Directories and Copy Defaults:**\n   - Create the `data/0-org-setup/` and `providers/` directories inside the confirmed `local_path` (`mkdir -p <LOCAL_PATH>/data/0-org-setup/ <LOCAL_PATH>/providers/`).\n   - **Copy** (do not move) the `defaults.yaml` from the chosen dataset folder to `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` using `cp`.\n8. **Edit the Copied Defaults File:** Use the `replace` tool to edit the *copied* `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` file.\n   - Populate `global.billing_account`, `global.organization.id`, `global.organization.domain`, and `global.organization.customer_id` (using the values gathered in Phase 2). Note that `customer_id` may not be present for GCD.\n   - Populate `context.iam_principals.gcp-organization-admins` (using the Admin Principal determined in Phase 2).\n   - Populate the service-specific locations gathered in the previous step into the `context.locations` block and map them appropriately in `projects.defaults.locations`.\n   - Populate `output_files.local_path` with the confirmed path.\n   - If additional context was provided, add it to the `context` block.\n   - *If GCD*, ensure the `overrides.universe` block is present with `domain`, `prefix`, and specific identity overrides.\n9. Run YAML validation: `yamllint -c .yamllint --no-warnings <LOCAL_PATH>/data/0-org-setup/defaults.yaml`. Handle missing tool errors as described in the Core Principles.\n10. **Create Configuration Files:**\n    - Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`).\n    - In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `\"datasets/classic\"`), but the `paths.defaults` must point to the absolute path of the copied defaults file.\n    - *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`.\n11. **Present Configuration and Halt:** Briefly tell the user you have created and validated the baseline configuration files. You MUST stop execution immediately, present the generated files, ask the user if they are ready to proceed with **Step 8 (Organization Policy Import Check & Essential Contacts)**, and wait for their response. Do not proceed to Step 8 or run more tools in this turn.\n\n### Step 8: Organization Policy Import Check & Essential Contacts\n\n1. **Organization Policy Import Check:**\n   - Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported.\n   - Provide (or execute if automatic) the command to list current policies:\n     ```bash\n     gcloud org-policies list --organization=\"<ORG_ID>\" --format=\"value(constraint)\"\n     ```\n   - **STOP Execution and Wait:** You MUST stop execution immediately here, ask the user to run the command, and wait for them to paste the output. Do NOT proceed to Step 9 or update any files until the user has explicitly provided the output of this command.\n   - **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them.\n\n2. **Essential Contacts Handling:**\n   - Explain to the user that FAST Stage 0 datasets configure essential contacts as part of the organization configuration, and simultaneously enforce the Essential Contacts domain restriction organization policy (`essentialcontacts.allowedContactDomains`). Any essential contacts defined must match the allowed domains in the organization policy, otherwise Terraform deployment will fail with policy violation errors.\n   - Ask the user to choose how they want to handle essential contacts:\n     - **Option A (Define Essential Contact & Update Org Policy - Recommended):**\n       - Prompt the user for the essential contact email address.\n       - If the currently authenticated identity / deploying principal (from Phase 1/2) is an email-like principal (e.g. `user@example.com` or `user:user@example.com`), offer the option to reuse it.\n       - If the deploying principal is a Workforce Identity Federation (WIF) identity (i.e. starting with `principal://`), skip this option and prompt the user to provide a valid email address.\n       - Update the essential contact in `<LOCAL_PATH>/data/0-org-setup/defaults.yaml` (under `context.email_addresses` or `projects.defaults.contacts`) using the `replace` tool.\n       - Update the organization policy in `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool so that `essentialcontacts.allowedContactDomains` allows the domain part of the provided email (e.g. adding `@<EMAIL_DOMAIN>` to the allowed values).\n     - **Option B (Allow All Domains in Org Policy):**\n       - If the user does not want to restrict domains or wants to bypass domain restrictions, update `<LOCAL_PATH>/data/0-org-setup/organization/org-policies/essentialcontacts.yaml` using the `replace` tool to set the policy rule to allow all values (`allow: all: true`), commenting out the existing restricted rule setup. Do NOT delete or empty the file (to keep it as a reference and avoid Terraform failures on empty YAML files).\n\n### Step 9: Wrap-up & Apply\n\n1. **Link Configuration:** Instruct the user to link the final configuration files to the stage directory using the `fast-links.sh` script. They should navigate to `fast/stages/0-org-setup/` and run:\n   ```bash\n   ../../fast-links.sh <LOCAL_PATH>\n   ```\n   Explain that they should then copy and run the commands printed by the script to create the links.\n   **Note:** If they are not on GCD, they can ignore the provider file linking command printed by the script for the first run.\n2. Remind the user that the prerequisite phase is complete.\n3. Instruct them to run `terraform init` and `terraform apply`.\n4. Remind them to delete the temporary project and reset their gcloud default project to `iac-0` after a successful apply. Provide the exact commands for them to copy and use later:\n   ```bash\n   gcloud projects delete <TEMP_PROJECT_ID>\n   gcloud config set project <IAC_PROJECT_ID>\n   ```\n\n<!-- ... +8 more modular skills available via /api/skills/GoogleCloudPlatform/cloud-foundation-fabric?path={path} -->\n</yakaai_skills>"}