{
  "$ref": "#/definitions/ForgeYaml",
  "definitions": {
    "ForgeYaml": {
      "type": "object",
      "properties": {
        "forge_version": {
          "type": "string",
          "pattern": "^\\d+\\.\\d+(?:\\.\\d+)?(?:[-+][0-9A-Za-z.-]+)?$",
          "description": "Forge schema version this file targets (e.g. \"1.0\"). Determines which forge-render major and which schema validations apply at parse time."
        },
        "app_name": {
          "type": "string",
          "minLength": 3,
          "maxLength": 40,
          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
          "description": "App identifier (lowercase kebab-case, 3-40 chars, must start with a letter and end alphanumeric). Drives every downstream resource name — GitHub repo, IAM roles, K8s namespace, ECR repos, Aurora schema, SSO group prefixes. Reserved prefixes (csvc-, conservice-, aws-, app-, forge-, k8s-, infra-) are rejected."
        },
        "team": {
          "type": "string",
          "minLength": 2,
          "maxLength": 64,
          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
          "description": "Owning team's kebab-case slug. Resolves to `team-{team}@conservice.com` — the owning team's per-team PermissionSet inherits AWS access to this app's resources via the `team` AWS tag (stamped on every resource) and the `aws-team-{team}-{tier}@` wrapper → IDC group → team-keyed PSet chain. Must match an entry in forge's ALLOWED_TEAMS list."
        },
        "domain": {
          "type": "string",
          "minLength": 2,
          "maxLength": 64,
          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
          "description": "Optional domain grouping (kebab-case) for the app. Tagged on resources for ownership/cost rollup; does not affect provisioning shape."
        },
        "portfolio": {
          "$ref": "#/definitions/ForgeYaml/properties/domain",
          "description": "Optional portfolio grouping (kebab-case) for finance/cost-allocation rollups. Tagged on resources only."
        },
        "services": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "minLength": 1,
                "description": "Service name within the app. Drives every per-service resource — Deployment `{app_name}-{name}`, Service, ConfigMap, ECR repo. Lowercase letters/digits/dashes; must be unique within `services[]`."
              },
              "port": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 65535,
                "description": "TCP port the container listens on inside the pod. Required when `expose:` is set; omit for ClusterIP-only services or worker containers (a Deployment-only shape with no port is also accepted)."
              },
              "expose": {
                "type": "string",
                "enum": [
                  "public",
                  "internal"
                ],
                "description": "Per-service Gateway exposure. `\"public\"` = HTTPRoute on the external Gateway (internet-facing, ALB-OIDC enforced); `\"internal\"` = HTTPRoute on the internal Gateway (VPN-only, no public DNS); omitted = ClusterIP-only (in-cluster traffic only) when `port` is set, or Deployment-only (worker, no Service at all) when `port` is omitted. v1 cap: at most one service per app may set this — split into separate apps for multi-routed shapes."
              },
              "health_path": {
                "type": "string",
                "description": "HTTP path the container serves for liveness/readiness probes (e.g. /healthz). Renderer emits probes only when both `health_path` AND `port` are set; omit for non-HTTP workers."
              },
              "dockerfile": {
                "type": "string",
                "description": "Path to the Dockerfile, relative to `context` (default: Dockerfile). Override for monorepos with per-service Dockerfiles like Dockerfile.worker."
              },
              "context": {
                "type": "string",
                "description": "Docker build context, relative to repo root (default: `.`). Override for monorepos where the service's code lives in a sub-directory."
              },
              "replicas": {
                "type": "integer",
                "minimum": 0,
                "description": "Replica count for the Deployment (default: 2 when `port` is set, 1 otherwise). HPA is not yet wired — this is a fixed count. Set 0 to suspend deployment without removing the resources."
              },
              "env": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Static env vars baked into the Deployment manifest (UPPER_SNAKE_CASE keys, string values). Use for non-secret values that don't need per-env overrides; prefer `app_config_keys` (ConfigMap) for env-tunable values and ESO for secrets."
              },
              "resources": {
                "type": "object",
                "properties": {
                  "cpu_request": {
                    "type": "string",
                    "description": "Pod CPU request in Kubernetes notation, e.g. \"100m\" or \"500m\". Sets the schedulability floor; Karpenter scales nodes to fit. No CPU limit is set by design — avoids throttling regressions. If you set the `resources` block at all, set all three fields (per-field fallback is not supported today)."
                  },
                  "memory_request": {
                    "type": "string",
                    "description": "Pod memory request in Kubernetes notation, e.g. \"256Mi\" or \"1Gi\". Set higher when the container has known steady-state memory needs. If you set the `resources` block at all, set all three fields (per-field fallback is not supported today)."
                  },
                  "memory_limit": {
                    "type": "string",
                    "description": "Pod memory limit in Kubernetes notation. When exceeded, the container is OOMKilled with a clear signal rather than throttled — set this to match worst-case observed memory. If you set the `resources` block at all, set all three fields (per-field fallback is not supported today)."
                  }
                },
                "additionalProperties": false,
                "description": "Pod resource requests and limits (CPU + memory). Omit this block entirely to get platform defaults (100m CPU / 256Mi memory request / 512Mi memory limit); when set, all three fields must be present — per-field fallback is not supported today."
              }
            },
            "required": [
              "name"
            ],
            "additionalProperties": false
          },
          "default": [],
          "description": "Containers Forge builds and deploys for this app. Optional: omit or pass `[]` for resource-only apps (S3 + DDB + queues with no runtime code in Conservice). When non-empty, `language` is required at the top level."
        },
        "language": {
          "type": "string",
          "enum": [
            "typescript",
            "javascript",
            "python",
            "go",
            "csharp",
            "java",
            "rust"
          ],
          "description": "Runtime/language of the app's services. Required when `services.length > 0`. Closed enum: typescript / javascript / python / go / csharp / java / rust. Lowercase, no version suffix — identifies the ecosystem, not the specific version. Ignored on resource-only apps (services: []) but carried through round-trips. Drives per-language Dockerfile base image + env contract emit."
        },
        "dns": {
          "type": "object",
          "properties": {
            "required": {
              "type": "boolean",
              "description": "Whether DNS resolution is required for the app (default: false). When true, the renderer emits the ExternalDNS annotation on the HTTPRoute; only a service with `expose:` set can satisfy `required: true`."
            },
            "zone": {
              "type": "string",
              "enum": [
                "conservice.ai",
                "conservice.cloud",
                "capturis.ai",
                "svc.conservice.ai"
              ],
              "description": "Primary DNS zone. `conservice.ai` is the documented default for new internet-facing apps. `conservice.cloud` and `capturis.ai` are peer primaries for apps with audience/brand reason to live there. `svc.conservice.ai` is reserved for AWS infra CNAMEs and is rejected for forge apps by the scaffold-input refine (use `dns.zone=conservice.ai` with `services[].expose: \"internal\"` for VPN-internal apps)."
            },
            "hostname": {
              "type": "string",
              "description": "Optional explicit hostname override (e.g. `rates-prod.conservice.ai`). Default is `{app_name}.{zone}`; override only when the app needs a different external name from its app name."
            },
            "aliases": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "zone": {
                    "type": "string",
                    "enum": [
                      "conservice.ai",
                      "conservice.cloud",
                      "capturis.ai",
                      "svc.conservice.ai"
                    ],
                    "description": "TLD this alias publishes into. Must differ from the primary `dns.zone`. `svc.*` rejected (reserved for AWS infra CNAMEs)."
                  },
                  "hostname": {
                    "type": "string",
                    "description": "Full hostname for this alias (e.g., `auth.conservice.cloud`). Must end with the alias's `zone` value. No `{app_name}.{zone}` default — aliases require an explicit hostname."
                  }
                },
                "required": [
                  "zone",
                  "hostname"
                ],
                "additionalProperties": false,
                "description": "Sister-TLD alias entry. Renders an additional HTTPRoute on the same Gateway pointing at the same backend Service so one workload answers on multiple TLDs."
              },
              "maxItems": 5,
              "description": "Optional sister hostnames on additional zones for the same workload. Each alias renders an additional HTTPRoute on the same Gateway pointing at the same backend Service (TLS terminates at the NLB via the wildcard cert per TLD). Used for multi-TLD apps like the auth front-door — primary on `conservice.ai`, sisters on `conservice.cloud` and `capturis.ai`. Each alias's `zone` must differ from the primary `dns.zone` and not start with `svc.`; each alias's `hostname` must end with its `zone`."
            }
          },
          "additionalProperties": false,
          "description": "Optional DNS configuration (zone + custom hostname). Required only when one of the services has `expose:` set; omit for purely internal apps."
        },
        "app_config_keys": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^[A-Z][A-Z0-9_]*$"
          },
          "description": "Optional list of UPPER_SNAKE_CASE config keys Forge propagates from GitHub Actions `vars` → ConfigMap → pod env. Use for non-secret runtime tunables; secrets flow through ESO + AWS Secrets Manager instead."
        },
        "env_vars": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "propertyNames": {
              "enum": [
                "prev",
                "stg",
                "prod"
              ]
            }
          },
          "propertyNames": {
            "pattern": "^[A-Z][A-Z0-9_]*$"
          },
          "description": "Static per-env config variables injected directly into the app ConfigMap. Each key is an UPPER_SNAKE_CASE env var name; the value is a map of env → string (prev/stg/prod). Use for non-secret config that differs per environment (e.g. EXTERNAL_HOSTNAME). Secrets should use app_config_keys + GitHub Environment Secrets instead."
        },
        "resources": {
          "type": "object",
          "properties": {
            "s3": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "versioning": {
                    "type": "boolean",
                    "description": "Enable S3 object versioning on the bucket (default: true). All platform buckets default versioning ON; set false only when you are certain you don't want object history."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "team": {
                          "type": "string",
                          "minLength": 2,
                          "maxLength": 64,
                          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
                          "description": "Team kebab slug; reconciler resolves to `team-{team}@conservice.com` at apply time."
                        },
                        "tier": {
                          "type": "string",
                          "enum": [
                            "admin",
                            "readonly"
                          ],
                          "description": "Platform tier — \"admin\" (full AWS Console + DB admin via SSO) or \"readonly\" (read-only). App-tier values (\"admins\", \"users\") are NOT declarable here."
                        }
                      },
                      "required": [
                        "team",
                        "tier"
                      ],
                      "additionalProperties": false,
                      "description": "Per-resource cross-team grant — materializes as a resource-policy entry (S3 bucket policy, SQS queue policy, DDB resource policy, EventBridge bus policy) granting the named team's per-team PermissionSet tiered access to THIS resource. Does NOT grant app-wide access; scope is the single resource the block belongs to."
                    },
                    "description": "Per-bucket team grants. List of {team, tier} pairs; the renderer will nest `team-{team}@conservice.com` into the per-bucket tier group. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "email": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 254,
                          "description": "Lowercased @conservice.com email of the user to direct-add to the platform-tier group."
                        },
                        "tier": {
                          "type": "string",
                          "enum": [
                            "admin",
                            "readonly"
                          ],
                          "description": "Platform tier — \"admin\" or \"readonly\". App-tier values are rejected."
                        }
                      },
                      "required": [
                        "email",
                        "tier"
                      ],
                      "additionalProperties": false,
                      "description": "Per-resource per-user grant — narrow exception adding a single individual to this resource's tiered access via an exception group + scoped PermissionSet assignment. Use sparingly; `team_grants` is the preferred shape."
                    },
                    "description": "Per-bucket user grants. List of {email, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release. Use sparingly; team_grants is the preferred shape."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "group": {
                          "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items/properties/email",
                          "description": "Google group email (lowercased @conservice.com) — e.g. `conservice-finance@conservice.com`. Reconciler nests this group into the per-resource tier group at apply time."
                        },
                        "tier": {
                          "type": "string",
                          "enum": [
                            "admin",
                            "readonly"
                          ],
                          "description": "Platform tier — \"admin\" (read-write) or \"readonly\" (view-only). Same semantics as team_grants/user_grants tier."
                        }
                      },
                      "required": [
                        "group",
                        "tier"
                      ],
                      "additionalProperties": false,
                      "description": "Per-resource group grant — nests a Google group (e.g. `conservice-finance@conservice.com`) into the resource's tier group. Used for non-team grants (departmental, cross-functional) where `team_grants` doesn't fit."
                    },
                    "description": "Per-bucket Google-group grants. List of {group, tier} pairs to grant non-team Google groups (e.g. `conservice-finance@conservice.com`) tiered access scoped to THIS bucket — recipient gets bucket-only access, with no implicit grant to other resources the app owns. Materializes via the same resource-policy / exception-group path as team_grants / user_grants. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "access": {
                    "type": "string",
                    "enum": [
                      "open",
                      "team",
                      "app"
                    ],
                    "description": "Cross-app consume policy. `open` = any app in the org may declare `consumes:` against this resource. `team` = only apps owned by a team in `allowed_teams` may consume. `app` = only apps in `allowed_apps` may consume. Auto-defaults to `team` for databases and for resources with `tags.sensitivity ∈ {pii, pci, hipaa, soc2}` — declare explicitly to override. Resource-level field; the app-level `access:` block governs IAM tier grants and is unrelated."
                  },
                  "allowed_teams": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/domain"
                    },
                    "description": "List of team kebab slugs whose apps may declare `consumes:` against this resource (when `access: team`). Required non-empty when `access` is explicitly set to `team` by the dev. Teams are the same kebab slugs used by `team:` and `team_grants[].team` — they resolve to `team-{slug}@conservice.com` at apply time."
                  },
                  "allowed_apps": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/app_name"
                    },
                    "description": "List of app names whose pods may declare `consumes:` against this resource (when `access: app`). Required non-empty when `access` is explicitly set to `app` by the dev. App names follow the same kebab-case rules as `app_name` (reserved prefixes rejected)."
                  },
                  "tags": {
                    "type": "object",
                    "properties": {
                      "sensitivity": {
                        "type": "string",
                        "enum": [
                          "pii",
                          "pci",
                          "hipaa",
                          "soc2",
                          "public",
                          "internal"
                        ],
                        "description": "Data sensitivity classification (closed enum). Setting `pii`, `pci`, `hipaa`, or `soc2` auto-defaults `access: team` (cross-app consume is gated to allowed_teams). `public` / `internal` are positive-intent tags with no auto-default. Closed enum so typos like `confidential` fail at parse time."
                      }
                    },
                    "additionalProperties": false,
                    "description": "Resource-level tags. Today only `sensitivity` is recognized (drives auto-default of `access: team` for `pii`/`pci`/`hipaa`/`soc2`)."
                  }
                },
                "additionalProperties": false,
                "description": "S3 bucket. Bucket name is emitted as `conservice-{env}-{app_name}-{key}` (S3's global namespace). KMS encryption + public-access-block are always on; `versioning` is the only currently-declarable knob. Per-bucket `team_grants` / `user_grants` / `group_grants` are accepted at the schema level today; runtime effect ships in a follow-on release."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "S3 buckets keyed by logical name."
            },
            "sqs": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "dlq": {
                    "type": "boolean",
                    "description": "Provision a dead-letter queue and wire the redrive policy on the main queue (default: true). Disable only for queues where DLQ semantics genuinely don't apply."
                  },
                  "dlq_retention_seconds": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Message retention on the DLQ in seconds (default: 1209600 / 14 days, the AWS max). DLQ messages are usually inspected manually so this is intentionally generous."
                  },
                  "visibility_timeout": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Message visibility timeout in seconds (default: 30). Set higher when the consumer's per-message processing time can exceed 30s — otherwise the same message is redelivered while still being processed."
                  },
                  "retention_seconds": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Message retention on the main queue in seconds (default: 345600 / 4 days; AWS max 14 days). Increase for queues where consumers may be down for extended maintenance."
                  },
                  "max_receive_count": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Max times a message can be received before redrive to the DLQ (default: 5). Lower for fail-fast semantics; higher when transient retries are normal."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "Per-queue team grants. List of {team, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-queue user grants. List of {email, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release. Use sparingly; team_grants is the preferred shape."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-queue Google-group grants. List of {group, tier} pairs to grant non-team Google groups tiered access scoped to THIS queue — recipient gets queue-only access, with no implicit grant to other resources the app owns. Materializes via the same resource-policy / exception-group path as team_grants / user_grants. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "additionalProperties": false,
                "description": "SQS queue. Server-side encryption via SQS-managed keys is always on; the standard pod-role grant covers Send/Receive/Delete on the queue and its DLQ. Per-queue `team_grants` / `user_grants` / `group_grants` are accepted at the schema level today; runtime effect ships in a follow-on release."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "SQS queues keyed by logical name. DLQs are provisioned by default — see the per-queue `dlq` knob."
            },
            "sns": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "additionalProperties": false,
                "description": "SNS topic. v1 has no per-topic knobs — presence of the key provisions the topic plus the standard pod-role Publish grant. Strict means unknown keys (e.g. `subscriptions`) fail validation. Per-resource `access`/`allowed_teams`/`allowed_apps`/`tags` may be set to gate cross-app consumes."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "SNS topics keyed by logical name."
            },
            "dynamodb": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "hash_key": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Partition key attribute name (required). The table's primary lookup dimension."
                  },
                  "hash_key_type": {
                    "type": "string",
                    "enum": [
                      "S",
                      "N",
                      "B"
                    ],
                    "description": "Partition key attribute type (default: \"S\"). S=string, N=number, B=binary."
                  },
                  "range_key": {
                    "type": "string",
                    "description": "Optional sort key attribute name. Add when items must be ordered or range-scanned within a partition."
                  },
                  "range_key_type": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/dynamodb/additionalProperties/properties/hash_key_type",
                    "description": "Sort key attribute type (default: \"S\"). Same encoding as `hash_key_type`."
                  },
                  "billing_mode": {
                    "type": "string",
                    "enum": [
                      "PAY_PER_REQUEST",
                      "PROVISIONED"
                    ],
                    "description": "Billing model (default: PAY_PER_REQUEST). Switch to PROVISIONED only when you have predictable steady-state throughput and want fixed pricing."
                  },
                  "gsi": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "hash_key": {
                          "type": "string",
                          "minLength": 1,
                          "description": "Partition key attribute name for the GSI (required). Names the attribute on which secondary-index queries partition."
                        },
                        "range_key": {
                          "type": "string",
                          "description": "Optional sort key attribute name for the GSI. Add when GSI queries need ordered range scans inside a partition."
                        },
                        "projection_type": {
                          "type": "string",
                          "description": "What attributes are projected into the GSI (default: ALL). Use \"KEYS_ONLY\" or \"INCLUDE\" to reduce per-write cost when you don't need the full item."
                        }
                      },
                      "required": [
                        "hash_key"
                      ],
                      "additionalProperties": false,
                      "description": "Global secondary index — keyed map of index logical name → key shape. Add when you need a second access pattern on different attributes than the table's primary key."
                    },
                    "propertyNames": {
                      "minLength": 1,
                      "maxLength": 64,
                      "pattern": "^[a-z][a-z0-9_-]*$"
                    },
                    "description": "Optional global secondary indexes — map of index logical name → key shape. Each key is a lowercase, kebab-/underscore-safe identifier; AWS index names are derived from this."
                  },
                  "ttl_attribute": {
                    "type": "string",
                    "description": "Item attribute (number, epoch seconds) DynamoDB uses to delete expired items automatically. Omit for tables without TTL."
                  },
                  "point_in_time_recovery": {
                    "type": "boolean",
                    "description": "Enable continuous backups + 35-day point-in-time recovery (default: true). Standard AWS feature; leave on for any table with non-cache data."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "Per-table team grants. List of {team, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-table user grants. List of {email, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release. Use sparingly; team_grants is the preferred shape."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-table Google-group grants. List of {group, tier} pairs to grant non-team Google groups tiered access scoped to THIS table — recipient gets table-only access, with no implicit grant to other resources the app owns. Materializes via the same resource-policy / exception-group path as team_grants / user_grants. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "required": [
                  "hash_key"
                ],
                "additionalProperties": false,
                "description": "DynamoDB table. KMS-encrypted by default; the pod role gets read/write/query/scan on this table's ARN and any indexes. Per-table `team_grants` / `user_grants` / `group_grants` are accepted at the schema level today; runtime effect ships in a follow-on release."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "DynamoDB tables keyed by logical name. PAY_PER_REQUEST + PITR-on by default."
            },
            "eventbridge": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "rules": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "pattern": {
                          "description": "EventBridge event pattern (raw JSON shape) the rule matches. AWS validates the pattern at apply time; this schema accepts any JSON to allow the full pattern grammar."
                        },
                        "description": {
                          "type": "string",
                          "description": "Optional human-readable description shown in the EventBridge console."
                        }
                      },
                      "additionalProperties": false,
                      "description": "EventBridge rule. The rule's target wiring (target ARN, role, input transformer) is module-internal today; only `pattern` and `description` are dev-facing here."
                    },
                    "propertyNames": {
                      "minLength": 1,
                      "maxLength": 64,
                      "pattern": "^[a-z][a-z0-9_-]*$"
                    },
                    "description": "Rules attached to this EventBridge bus, keyed by rule logical name (lowercase, max 64 chars)."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "Per-bus team grants. List of {team, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-bus user grants. List of {email, tier} pairs. Accepted at the schema level today; runtime effect ships in a follow-on release. Use sparingly; team_grants is the preferred shape."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-bus Google-group grants. List of {group, tier} pairs to grant non-team Google groups tiered access scoped to THIS bus — recipient gets bus-only access, with no implicit grant to other resources the app owns. Materializes via the same resource-policy / exception-group path as team_grants / user_grants. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "additionalProperties": false,
                "description": "EventBridge custom event bus. Bus name is emitted as `{env}-{region}-{app_name}-{key}-bus`; the pod role gets PutEvents on the bus ARN. Per-bus `team_grants` / `user_grants` / `group_grants` are accepted at the schema level today; runtime effect ships in a follow-on release."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "EventBridge custom buses keyed by logical name; each bus may declare rules."
            },
            "stepfunctions": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "STANDARD",
                      "EXPRESS"
                    ],
                    "description": "State machine type (default: STANDARD). EXPRESS is cheaper and faster for short, high-throughput workflows but lacks history retention; pick STANDARD for anything that needs replay/audit."
                  },
                  "definition": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Amazon States Language definition (JSON or YAML string) for the workflow. Required."
                  },
                  "log_level": {
                    "type": "string",
                    "enum": [
                      "ALL",
                      "ERROR",
                      "FATAL",
                      "OFF"
                    ],
                    "description": "CloudWatch logging detail (default: ALL). Set ERROR or FATAL to reduce log volume; OFF disables logging entirely."
                  },
                  "log_retention_days": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "CloudWatch log retention in days (default: 30)."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "required": [
                  "definition"
                ],
                "additionalProperties": false,
                "description": "Step Functions state machine. CloudWatch logs go to `/aws/vendedlogs/states/{name}`; the pod role gets StartExecution + DescribeExecution on the state-machine ARN."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "Step Functions state machines keyed by logical name. STANDARD type with CloudWatch logging at ALL by default."
            },
            "bedrock": {
              "type": "object",
              "properties": {
                "model_ids": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "pattern": "^[a-z0-9][a-z0-9.:_\\-]*$"
                  },
                  "minItems": 1,
                  "description": "Bedrock model IDs the app's pod role may invoke. Each ID must be a valid Bedrock invocation target — either a versioned foundation-model ID ending in \":N\" (e.g. \"amazon.titan-embed-text-v2:0\", \"cohere.command-r-v1:0\") or a cross-region inference profile starting with a region prefix (e.g. \"us.anthropic.claude-sonnet-4-20250514-v1:0\"). Anthropic models REQUIRE a regional inference profile prefix (\"us.\" / \"eu.\" / \"apac.\" / \"global.\" / \"ap.\") — bare \"anthropic.*\" foundation-model IDs are rejected because AWS Bedrock fails them at invocation with \"on-demand throughput isn't supported\". Required non-empty when bedrock: is set; the renderer wildcards across regions to support cross-region inference profiles. Bare foundation-model names without a versioned `-vN:0` suffix are also rejected."
                },
                "knowledge_bases": {
                  "type": "boolean",
                  "description": "Off by default; set true to grant the pod role `bedrock:Retrieve` + `bedrock:RetrieveAndGenerate` on knowledge-base resources in the app's account."
                },
                "guardrails": {
                  "type": "boolean",
                  "description": "Off by default; set true to grant the pod role `bedrock:ApplyGuardrail` on guardrail resources in the app's account."
                }
              },
              "required": [
                "model_ids"
              ],
              "additionalProperties": false,
              "description": "Bedrock model-invocation grants for the pod role. Singleton (one per app); rejects `enabled` and `models` typos that used to silently fall through."
            },
            "database": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "extensions": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "uuid-ossp",
                        "vector",
                        "pg_trgm",
                        "hstore",
                        "citext",
                        "postgis",
                        "btree_gist",
                        "btree_gin",
                        "unaccent",
                        "fuzzystrmatch"
                      ]
                    },
                    "description": "PostgreSQL extensions to install in this database (default: none). Allowlist: uuid-ossp, vector, pg_trgm, hstore, citext, postgis, btree_gist, btree_gin, unaccent, fuzzystrmatch. Same allowlist applies to per-PR databases."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "team": {
                          "type": "string",
                          "minLength": 2,
                          "maxLength": 64,
                          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
                          "description": "Team kebab slug; on apply, team's per-team PermissionSet (TeamAdmin{Team}{Env} or TeamReadOnly{Team}{Env}) gains `rds-db:connect` on `arn:aws:rds-db:*:*:dbuser:*/aws-{app}-db-{tier}` for this app's databases."
                        },
                        "tier": {
                          "type": "string",
                          "enum": [
                            "admin",
                            "readonly"
                          ],
                          "description": "DB tier — \"admin\" (full DB access via PG role `aws-{app}-db-admin`) or \"readonly\" (SELECT-only via PG role `aws-{app}-db-readonly`). Both roles are created at the Aurora cluster level by the platform; they inherit object grants from per-app tier roles (`{app}_admin` / `{app}_readonly`) on every database the app owns."
                        }
                      },
                      "required": [
                        "team",
                        "tier"
                      ],
                      "additionalProperties": false,
                      "description": "Per-DB team grant — adds `rds-db:connect` on the per-app login role `aws-{app}-db-{tier}` to the team's per-team PermissionSet. Multiple teams requesting the same tier share one cluster-level login role (anti-quadratic). Recipient gets DB-only AWS access (no S3, queues, secrets-other-than-the-DB-connection, AWS console for any other resource of the app)."
                    },
                    "description": "DB-only team grants — list of {team, tier} pairs. Each entry materializes the PG-side role chain (`{app}_{tier}` tier role + `aws-{app}-db-{tier}` login role on the Aurora cluster) and adds the team's per-team PermissionSet `rds-db:connect` ARN. Recipient can `psql` into THIS database with the named tier's permissions, with NO access to the app's S3 buckets, queues, secrets (other than the DB connection secret), or AWS console for any other resource. **The owning team (forge.yaml `team:`) is implicitly granted `tier: admin` — no need to redeclare it here.** Add explicit entries to grant OTHER teams access, or to grant the owning team `tier: readonly` (which is not implicitly added)."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "user": {
                          "type": "string",
                          "minLength": 2,
                          "maxLength": 64,
                          "pattern": "^[a-z][a-z0-9._-]*[a-z0-9]$",
                          "description": "Google username (left side of @conservice.com — e.g. `alice` or `bob.smith`). Do NOT include the @conservice.com suffix."
                        },
                        "tier": {
                          "type": "string",
                          "enum": [
                            "admin",
                            "readonly"
                          ],
                          "description": "DB tier — \"admin\" or \"readonly\". Same semantics as team_grants[].tier."
                        }
                      },
                      "required": [
                        "user",
                        "tier"
                      ],
                      "additionalProperties": false,
                      "description": "Per-DB user grant — narrow per-user exception adding `rds-db:connect` on `aws-{app}-db-{tier}` for a single individual (e.g. break-glass / audit access). Use sparingly; team_grants is preferred for keeping access decisions reviewable in code."
                    },
                    "description": "DB-only user grants — list of {user, tier} pairs (Google username, no @-suffix). Per-user exception path adding `rds-db:connect` on `aws-{app}-db-{tier}`. Use sparingly; team_grants is preferred for code-review auditability."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "DB-only Google-group grants. List of {group, tier} pairs for non-team Google groups (e.g. `conservice-finance@conservice.com`). Materializes via the `team_dbs` map enumeration at the per-team PSet layer. Accepted at the schema level today; runtime effect ships in a follow-on release."
                  },
                  "connection_limit": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Per-database max concurrent connections (default: -1 / unlimited at the module). Set when the app's connection pool is misbehaving and you need a cluster-side ceiling."
                  },
                  "migrations": {
                    "type": "object",
                    "properties": {
                      "command": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "minLength": 1
                        },
                        "minItems": 1,
                        "description": "Migration command argv array (e.g. [\"npm\", \"run\", \"migrate\"]). Each entry is a literal string — no shell expansion. The Job re-uses the app image and runs this against the per-env (or per-PR) database before app pods start."
                      },
                      "runs_on": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "enum": [
                            "prev",
                            "stg",
                            "prod"
                          ]
                        },
                        "minItems": 1,
                        "description": "Which env overlays emit the migration Job (default: [\"prev\", \"stg\", \"prod\"], i.e. every overlay). Scope to a subset (e.g. [\"prev\", \"stg\"]) to skip prod migrations during a schema-stable period."
                      }
                    },
                    "required": [
                      "command"
                    ],
                    "additionalProperties": false,
                    "description": "Schema-bootstrap migration runner. When set, the renderer emits a `migration-job.yaml` ArgoCD Sync hook per env overlay; sync-wave ordering ensures app Deployments only roll after the Job completes."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "additionalProperties": false,
                "description": "Per-app PostgreSQL database. The database lives on the per-env Aurora cluster; access is via IAM tokens (no passwords on the dev surface). The renderer composes the conservice-app-database leaf module per `database` entry. Cross-team / per-user DB access is declared via `team_grants` / `user_grants` only — the legacy `admin_groups` / `readonly_groups` / `admin_users` / `readonly_users` fields have been removed."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "Per-app PostgreSQL databases keyed by logical name."
            },
            "temporal": {
              "type": "object",
              "properties": {
                "retention_days": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "description": "Workflow execution history retention in days (default: 30). Increase only when you need longer historical query/replay windows; longer retention costs more in Temporal Cloud."
                },
                "api_key_expiry": {
                  "type": "string",
                  "minLength": 1,
                  "description": "ISO-8601 expiry timestamp for the namespace's API key (e.g. `2027-04-01T00:00:00Z`). Required when the temporal block is present — pinned at scaffold time so render output stays deterministic. Rotate by editing this value and re-rendering."
                }
              },
              "required": [
                "api_key_expiry"
              ],
              "additionalProperties": false,
              "description": "Temporal Cloud namespace for the app. Singleton (one per app per env); namespace name is derived from `app_name` by the conservice-temporal module — `namespace` and `enabled` keys are rejected."
            },
            "firehoses": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "destination": {
                    "type": "string",
                    "const": "s3",
                    "description": "Firehose destination type. v1 only supports \"s3\"; Redshift / OpenSearch / Splunk destinations are deferred until a team needs them."
                  },
                  "bucket": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Logical key into `resources.s3` — names the destination bucket on the same forge.yaml. Cross-validation rejects dangling references at parse time, before terraform plan."
                  },
                  "prefix": {
                    "type": "string",
                    "default": "",
                    "description": "S3 key prefix for delivered records (default: empty). Use to namespace records inside a shared bucket (e.g. `raw/events/`)."
                  },
                  "buffer_size_mb": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 128,
                    "default": 5,
                    "description": "Buffer size in MiB before flushing to S3 (default: 5; AWS max: 128). Larger buffers = fewer + larger objects in S3 and lower delivery latency floor."
                  },
                  "buffer_interval_seconds": {
                    "type": "integer",
                    "minimum": 60,
                    "maximum": 900,
                    "default": 300,
                    "description": "Max time in seconds before flushing the buffer regardless of size (default: 300; AWS range 60-900). Lower for fresher data; higher for fewer S3 PUTs."
                  },
                  "compression": {
                    "type": "string",
                    "enum": [
                      "UNCOMPRESSED",
                      "GZIP",
                      "SNAPPY",
                      "ZIP",
                      "HADOOP_SNAPPY"
                    ],
                    "default": "GZIP",
                    "description": "Compression applied to delivered objects (default: GZIP). Cuts S3 storage and Athena scan cost; keep GZIP unless your downstream consumer can't decompress."
                  },
                  "access": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/access"
                  },
                  "allowed_teams": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_teams"
                  },
                  "allowed_apps": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/allowed_apps"
                  },
                  "tags": {
                    "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/tags"
                  }
                },
                "required": [
                  "destination",
                  "bucket"
                ],
                "additionalProperties": false,
                "description": "Kinesis Data Firehose delivery stream (S3 destination). The pod role gets Firehose:PutRecord/PutRecordBatch on this stream's ARN."
              },
              "propertyNames": {
                "minLength": 1,
                "maxLength": 64,
                "pattern": "^[a-z][a-z0-9_-]*$"
              },
              "description": "Kinesis Data Firehose delivery streams keyed by logical name. v1 supports S3 destination only."
            },
            "kms": {
              "type": "object",
              "additionalProperties": {
                "type": "object",
                "properties": {
                  "description": {
                    "type": "string",
                    "maxLength": 8192,
                    "description": "Optional human-readable description of the key's purpose (shown in the AWS KMS console). When `rotation: disabled` is set, the description should mention the compliance reason — auditor-friendly."
                  },
                  "key_spec": {
                    "type": "string",
                    "enum": [
                      "SYMMETRIC_DEFAULT"
                    ],
                    "description": "KMS key spec (default: \"SYMMETRIC_DEFAULT\"). v1 only accepts SYMMETRIC_DEFAULT — asymmetric key specs (RSA_*, ECC_*) need a different action allowlist (Sign/Verify/GetPublicKey) and are not yet supported."
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "minLength": 1
                    },
                    "minItems": 1,
                    "description": "Required non-empty list of KMS data-plane actions to grant to the pod role on this key. Allowed actions: Encrypt, Decrypt, GenerateDataKey, GenerateDataKeyWithoutPlaintext, ReEncryptFrom, ReEncryptTo, DescribeKey. Unknown verbs are rejected at parse time. Key administration verbs (CreateKey, ScheduleKeyDeletion, PutKeyPolicy, ...) are deliberately omitted — key lifecycle stays in Terraform."
                  },
                  "rotation": {
                    "type": "string",
                    "enum": [
                      "enabled",
                      "disabled"
                    ],
                    "description": "Annual key rotation toggle (default: \"enabled\"). Matches the platform default (`{env}-{region}-aurora-key`, `{env}-{region}-secrets-key` all rotation-on). Set to \"disabled\" only with a compliance reason in `description:` — auditor-friendly."
                  },
                  "tags": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Optional pass-through tags applied to the KMS key. Standard k/v string map; no platform-side validation beyond the type. Use for cost-allocation (`cost_center: ...`) or compliance markers."
                  },
                  "access": {
                    "type": "object",
                    "properties": {
                      "team_grants": {
                        "type": "array",
                        "items": {
                          "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                        },
                        "description": "Per-key team grants. Accepted at the schema level today; runtime effect ships in a follow-on release."
                      }
                    },
                    "additionalProperties": false,
                    "description": "Per-key access block (parallel to dynamodb's grant pattern). Accepted at the schema level today; runtime effect ships in a follow-on release. Cross-app KMS access via `consumes:` is also not yet supported."
                  }
                },
                "required": [
                  "actions"
                ],
                "additionalProperties": false,
                "description": "Per-app customer-managed KMS key (CMK) for app-initiated envelope encryption. Resource name `{env}-{region_code}-{app}-{key_name}-key`, alias `alias/{env}-{region_code}-{app}-{key_name}-key`. Auto-emits env vars `KMS_KEY_{KEY_NAME_UPPER_SNAKE}_ID` and `KMS_KEY_{KEY_NAME_UPPER_SNAKE}_ARN` (reserved `KMS_KEY_` prefix blocks shadowing). IAM grants follow the declared `actions[]` allowlist; rotation defaults ON (annual)."
              },
              "description": "Per-app customer-managed KMS keys (CMKs) for app-initiated envelope encryption, keyed by logical name (kebab-case, 2-20 chars). Rotation defaults ON; data-plane actions only. Distinct from AWS-managed SSE-KMS on DDB/S3 (transparent, no app-side IAM). Auto-emits `KMS_KEY_{NAME_UPPER}_ID` / `KMS_KEY_{NAME_UPPER}_ARN` env vars."
            }
          },
          "additionalProperties": false,
          "description": "AWS resources Forge provisions for the app via the conservice-app-resources Terraform module. Each top-level key (s3/sqs/sns/dynamodb/...) is a strict map keyed by logical resource name; unknown keys fail validation immediately."
        },
        "environments": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "properties": {
              "account_id": {
                "type": "string",
                "pattern": "^[0-9]{12}$",
                "description": "DEPRECATED: this field is ignored — account IDs are platform constants and are sourced from forge-render's ENVIRONMENTS map, not from forge.yaml. The schema accepts the field for back-compat with older forge.yaml files; remove it on the next regen."
              }
            },
            "additionalProperties": false,
            "description": "Per-env deployment target. Today carries no dev-tunable fields — env keys alone declare which envs the app deploys to. Future versions may add dev-tunable env-scope toggles."
          },
          "description": "DEPRECATED. Was: per-env deployment targets keyed by env name. Today: ignored at render time — the enabled-env list is derived from PLATFORM.ENVIRONMENTS minus `disabled_envs:`. Field is accepted for back-compat with existing forge.yaml files; translator no longer emits it. Use `disabled_envs:` to opt out of an env."
        },
        "disabled_envs": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "prev",
              "stg",
              "prod"
            ]
          },
          "description": "Platform-known env names this app should NOT deploy to (e.g. `[prev]` for tooling-only apps that skip per-PR previews). Omit the field for the default — all 3 envs (prev, stg, prod) enabled. Replaces the deprecated `environments:` block as the dev-meaningful \"should this app deploy here?\" knob."
        },
        "preview": {
          "type": "object",
          "properties": {
            "enabled": {
              "type": "boolean",
              "default": false,
              "description": "Enable per-PR preview environments for this app (default: false). When true, every PR opened against `main` gets an isolated environment at `pr-{N}-{app}.prev.conservice.ai` with per-PR-namespaced resources (DDB/SQS/etc. suffixed `-pr-{N}`). When `dns.hostname` is set, the leading label honors the override (zone-stripped) — e.g. `dns.hostname: demo.conservice.ai` → previews at `pr-{N}-demo.prev.conservice.ai`."
            }
          },
          "additionalProperties": false,
          "description": "Per-PR preview environment configuration. When `enabled: true`, every PR opened against `main` gets an isolated environment at `pr-{N}-{app}.prev.conservice.ai` (or `pr-{N}-{prefix}.prev.conservice.ai` when `dns.hostname` overrides the leading label)."
        },
        "auth": {
          "type": "object",
          "properties": {
            "access_mode": {
              "type": "string",
              "enum": [
                "restricted",
                "all"
              ],
              "default": "restricted",
              "description": "Access policy (default: \"restricted\"). \"restricted\" allows only members of the declared tier groups; \"all\" allows any authenticated @conservice.com user (Auth gates only authentication, not per-tier authorization)."
            },
            "strict": {
              "type": "boolean",
              "default": false,
              "description": "Strict mode (default: false). When true, the Auth admin UI cannot grant additional group access beyond what's declared here — only forge.yaml is authoritative. Recommended for prod-critical or Aurora-touching apps."
            },
            "self_register": {
              "type": "boolean",
              "default": false,
              "description": "Bootstrap-only flag (default: false). When true, Forge SKIPS the Auth admin-API write steps because the app self-registers via startup migration. Set true ONLY for the Auth Service itself; every other app leaves this false."
            },
            "hidden": {
              "type": "boolean",
              "default": false,
              "description": "Hide the app's tile from the Portal dashboard (default: false). The app remains reachable at its hostname; it just doesn't appear in the user's tile grid."
            },
            "tiers": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "pattern": "^[a-z][a-z0-9_]*$",
                    "description": "Tier name (lowercase letters/digits/underscores, e.g. \"admins\", \"users\"). Forge passes this to the Auth admin API and surfaces it as the X-Auth-Tier header on requests reaching the app, so the app can branch on tier."
                  },
                  "group": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 254,
                    "description": "Lowercased @conservice.com email of the Google group backing this tier (e.g. `app-rates-admins@conservice.com`). Forge creates the group if missing. When tiers `users` and `admins` are both declared, the `users` group is nested into the `admins` group so admins inherit user-tier access. Custom tier names are not auto-nested."
                  },
                  "seed_members": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "email"
                    },
                    "default": [],
                    "description": "Direct member emails added to the tier group at scaffold time (default: []). Used primarily for Auth bootstrap (e.g. `app-auth-admins` seeded with the initial platform-admin emails); empty for most apps. All entries must be @conservice.com."
                  }
                },
                "required": [
                  "name",
                  "group"
                ],
                "additionalProperties": false,
                "description": "Tier definition — maps an in-app role name to the Google group whose members hold that role."
              },
              "minItems": 1,
              "description": "Tier definitions for the app (at least one required when `auth:` is set). Each tier maps an in-app role (e.g. admins, users) to a Google group; the reconciler keeps the groups in sync with these definitions."
            }
          },
          "required": [
            "tiers"
          ],
          "additionalProperties": false,
          "description": "Optional Auth Service integration. When omitted, Forge applies the closed-restricted safe default at create time — single `admins` tier on `app-{app}-admins@conservice.com`. Set this block to override tier shape, access mode, or seed-member lists; the schema does NOT synthesize tiers here so on-disk forge.yaml stays a faithful echo of what the dev wrote."
        },
        "consumes": {
          "type": "object",
          "properties": {
            "resources": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "producer_app": {
                    "$ref": "#/definitions/ForgeYaml/properties/app_name",
                    "description": "Name of the producing app (e.g. `rates`). Must obey the same kebab-case rules as `app_name` (reserved prefixes rejected). The resolver looks for `infra/forge.yaml` in the producer's repo (`${dev_repo_prefix}${producer_app}`) at scaffold/modify time."
                  },
                  "resource_kind": {
                    "type": "string",
                    "enum": [
                      "s3",
                      "sqs",
                      "sns",
                      "dynamodb",
                      "eventbridge",
                      "stepfunctions",
                      "firehoses",
                      "database"
                    ],
                    "description": "Producer's resource kind — disambiguates `s3.foo` vs `sqs.foo` when both exist on the same producer. One of: s3, sqs, sns, dynamodb, eventbridge, stepfunctions, firehoses, database."
                  },
                  "resource_key": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 64,
                    "pattern": "^[a-z][a-z0-9_-]*$",
                    "description": "Producer's resource logical name (the map key under `resources.{kind}` in the producer's forge.yaml). The resolver verifies existence at scaffold time and lists available keys when it doesn't match."
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "minLength": 1
                    },
                    "minItems": 1,
                    "description": "High-level actions (e.g. [\"read\"], [\"write\"], [\"consume\"], [\"produce\"], [\"admin\"]). The action-expansion utility maps each to per-kind IAM actions at emit time; unknown high-level actions for a kind throw at scaffold time, NOT at schema parse (the schema layer treats actions as opaque strings to keep the kind/action coupling in one place — the expansion utility)."
                  }
                },
                "required": [
                  "producer_app",
                  "resource_kind",
                  "resource_key",
                  "actions"
                ],
                "additionalProperties": false,
                "description": "Single consumer-side resource declaration — names a producer + resource + actions the consumer wants. Resolved at scaffold/modify time against the producer's forge.yaml (`access` / `allowed_teams` / `allowed_apps`)."
              },
              "description": "List of cross-app resource declarations. Each entry names a producer + resource_kind + resource_key + actions. The resolver matches against the producer's `resources.{kind}.{key}` and the producer's `access` / `allowed_teams` / `allowed_apps` policy at scaffold/modify time."
            },
            "services": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "producer_app": {
                    "$ref": "#/definitions/ForgeYaml/properties/app_name",
                    "description": "Name of the producing app whose service you want to call."
                  },
                  "service_name": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Name of the producer's service (matches an entry in the producer's `services[].name`). The resolver verifies the service exists in the producer's forge.yaml. Mesh authorization emit is not yet wired; `consumes.services` is data-only today."
                  }
                },
                "required": [
                  "producer_app",
                  "service_name"
                ],
                "additionalProperties": false,
                "description": "Single consumer-side service declaration. Data-only today — no Istio AuthorizationPolicy CR is emitted yet."
              },
              "description": "List of cross-app service declarations. Data-only today — no Istio mesh-authz emit yet. Records intent so future releases can wire mesh policies without a schema-level migration."
            }
          },
          "additionalProperties": false,
          "description": "Optional cross-app consume declarations. Names other apps' resources/services this app needs IAM grants or mesh access for. Resolved at scaffold/modify time against the producer's `forge.yaml` (`access` / `allowed_teams` / `allowed_apps`). Same-account only — cross-account consumes raise `cross_account_not_supported` at resolve time."
        },
        "image_tags": {
          "type": "object",
          "properties": {
            "stg": {
              "type": "string",
              "minLength": 1,
              "maxLength": 128,
              "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9._-]*$"
            },
            "prod": {
              "type": "string",
              "minLength": 1,
              "maxLength": 128,
              "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9._-]*$"
            }
          },
          "additionalProperties": false,
          "description": "Image-tag promotion source-of-truth. Per-env container image tags Kargo writes after each promotion. Forge-render reads this at CI render time and applies the tag to every service's container image in the env's kustomize tree. Keys: `stg` / `prod` (prev sources its tag from the per-PR CI build, not from this map). Values: OCI image-tag strings. Missing entries render with a sentinel placeholder until the first Kargo promotion lands."
        },
        "replicas": {
          "type": "object",
          "properties": {
            "stg": {
              "type": "integer",
              "minimum": 0
            },
            "prod": {
              "type": "integer",
              "minimum": 0
            }
          },
          "additionalProperties": false,
          "description": "Per-env replica count source-of-truth. Sibling to `image_tags:`. Scaffolded as `{ stg: 0, prod: 0 }` so a freshly-scaffolded app stays ArgoCD-Healthy at zero pods; Kargo's `promote-app` ClusterPromotionTask bumps each key 0 → 2 on first promotion alongside writing `image_tags.{env}`. Keys: `stg` / `prod` (prev excluded — per-PR previews use a fixed per-PR replica count). Values: non-negative integers; coerced from string for resilience to Kargo's quoted-scalar yaml-update emission."
        },
        "canary": {
          "type": "boolean",
          "description": "Per-app canary opt-in. When `true`, the app's emitted terraform.yaml pins FORGE_RENDER_VERSION_CANARY (advance-on-every-publish) instead of FORGE_RENDER_VERSION_GENERAL (post-48h-bake-promotion). Reserved for SRE-owned canary apps (`forge-canary-*`); general apps omit or set `false` (default)."
        }
      },
      "required": [
        "forge_version",
        "app_name",
        "team"
      ],
      "additionalProperties": false
    }
  },
  "$schema": "http://json-schema.org/draft-07/schema#"
}
