{
  "$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. \"3.0\"). Determines which forge-render major and which schema validations apply at parse time."
        },
        "app_name": {
          "type": "string",
          "minLength": 3,
          "maxLength": 22,
          "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
          "description": "App identifier (lowercase kebab-case, 3-22 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. The 22-char cap keeps derived IAM role + S3 bucket names under their AWS length caps (64 / 63). Reserved prefixes (csvc-, conservice-, aws-, 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": 2,
                "maxLength": 40,
                "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
                "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. Schema 3.0+: multiple services may be exposed per app — each emits its own HTTPRoute on the gateway dictated by this field."
              },
              "gateway": {
                "type": "string",
                "enum": [
                  "alb",
                  "istio"
                ],
                "description": "Ingress gateway for this service — optional override; normally left unset and platform-derived. Derived from `auth` + `expose`: authenticated apps → `\"istio\"` (`istio-internal` for `expose: internal`, `istio-edge` for `expose: public`); no-auth apps (`auth: \"none\"`) → `\"alb\"`, off the ext_authz chain. `auth: \"none\"` + `gateway: \"istio\"` is rejected at schema validation."
              },
              "dns": {
                "type": "object",
                "properties": {
                  "hostname": {
                    "type": "string",
                    "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$",
                    "description": "Optional per-service hostname override (e.g. `auth.conservice.ai`). Default when omitted: the primary service (services[0]) gets `{app_name}.{dns.zone}`; subsequent exposed services get `{app_name}-{service}.{dns.zone}`. When only one service is exposed, it always gets the bare `{app_name}.{dns.zone}` form. Ordering in services[] matters — the first entry is primary. Must end with the app's `dns.zone` value."
                  }
                },
                "additionalProperties": false,
                "description": "Per-service DNS overrides (schema 3.0+). Today the only field is `hostname`; the app-level `dns.zone` and `dns.required` still apply across all exposed services."
              },
              "health_path": {
                "type": "string",
                "pattern": "^\\/[a-zA-Z0-9\\/_.\\-]*$",
                "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."
              },
              "language": {
                "type": "string",
                "enum": [
                  "typescript",
                  "javascript",
                  "python",
                  "go",
                  "csharp",
                  "java",
                  "rust"
                ],
                "description": "Per-service language override (same 7-lang enum). Defaults to the app-level top-level `language` when omitted. Drives this service's scaffold-generated placeholder (Dockerfile base image + placeholder app/dep manifest + env-contract emit). Once you point image.dockerfile at your own Dockerfile post-scaffold, that file controls the build/runtime."
              },
              "image": {
                "type": "object",
                "properties": {
                  "dockerfile": {
                    "type": "string",
                    "description": "Path to the Dockerfile, relative to the REPO ROOT (default: `Dockerfile`). When set, the build context is the repo root — write COPY paths root-relative. Mutually exclusive with the deprecated flat `dockerfile:`/`context:` keys (declaring both is a validation error)."
                  },
                  "target": {
                    "type": "string",
                    "description": "Multi-target Dockerfile stage name (default: none — the whole Dockerfile builds). Apps with multiple services SHOULD use one Dockerfile with N stages — shared base layer, smaller image graph. CI builds + pushes each target as `{app}:{sha}-{service}`."
                  },
                  "base": {
                    "type": "string",
                    "enum": [
                      "musl",
                      "glibc"
                    ],
                    "description": "C-library variant of the platform Node base image for the scaffold-generated Dockerfile. `\"musl\"` (default) = node:22-alpine; `\"glibc\"` = node:22-bookworm-slim (Debian) — REQUIRED for Node apps with glibc-linked native addons that ship no musl prebuild (e.g. `@temporalio/core-bridge` in a Temporal worker), which otherwise crash-loop with ERR_DLOPEN_FAILED. Only affects generated Node Dockerfiles (typescript/javascript) — ignored when you supply your own `image.dockerfile`, and a no-op for other languages."
                  }
                },
                "additionalProperties": false,
                "description": "Per-service build inputs. Apps with a single root Dockerfile can omit; defaults are `Dockerfile`, no target stage, and the musl base."
              },
              "dockerfile": {
                "type": "string",
                "description": "DEPRECATED in 3.0 — use `image.dockerfile` instead. Path to the Dockerfile, relative to `context` (default: Dockerfile). Accepted-but-soft-deprecated for 2.x back-compat; `image.dockerfile` wins when both are set."
              },
              "context": {
                "type": "string",
                "description": "DEPRECATED in 3.0 — `image.dockerfile` is path-relative-to-repo-root now. Docker build context (default: `.`). Accepted-but-soft-deprecated for 2.x back-compat; the renderer ignores `context` when `image.dockerfile` is set."
              },
              "auth": {
                "anyOf": [
                  {
                    "type": "string",
                    "const": "none"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "enum": [
                          "bearer"
                        ],
                        "description": "Optional non-default auth-mode declaration. `bearer` flags this service as one whose callers cannot follow OIDC redirects (MCP, CLI agents, programmatic clients) and turns it into a platform-validated bearer MCP: forge registers the service's canonical origin with the auth server (mcp-oauth) and the gateway enforces bearer tokens for it — no in-app auth code. Callers authenticate via RFC 9728 discovery from the app's origin (the origin is the token audience/resource indicator); tokens are issued to verified conservice.com identities and forge team membership is enforced at the RESOURCE (a non-member gets a token but a 403 at the MCP). See /docs/mcp-apps for the client token recipe (preview + steady-state). Preview bearer emit is symmetric at hydrate (forge#2129/#2131) — previews hydrated before that fix deployed can still `invalid_target`; re-hydrate (push a commit) to pick it up. Omitted = OIDC-cookie (the default for browser-facing services)."
                      }
                    },
                    "additionalProperties": false
                  }
                ],
                "description": "Per-service auth posture. `\"none\"` = an anonymous, role-less public surface (off the gateway ext_authz chain — pair with `iam: \"none\"`); otherwise an object selecting `kind: \"bearer\"`. Optional — omit to inherit the app-level auth posture."
              },
              "iam": {
                "type": "string",
                "const": "none",
                "description": "Set to `\"none\"` to make this service role-less (no pod IAM identity). Required for an anonymous public surface (`auth: \"none\"`). A role-less service may not declare `resources:`."
              },
              "replicas": {
                "type": "integer",
                "minimum": 0,
                "description": "Fixed replica count for the Deployment (default: 2 when `port` is set, 1 otherwise). Set 0 to suspend deployment without removing the resources. Mutually exclusive with `scaling` — use `scaling` for autoscaling, this for a fixed count."
              },
              "scaling": {
                "type": "object",
                "properties": {
                  "min_replicas": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Minimum replica count the autoscaler will scale down to. Set ≥ 2 for high availability across the rolling-update window and single-pod failures. This is the floor the Deployment runs at under no load."
                  },
                  "max_replicas": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Maximum replica count the autoscaler will scale up to under load. Must be ≥ `min_replicas`. Size it to peak expected traffic divided by per-pod throughput."
                  },
                  "target_cpu": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "description": "Target average CPU utilization (percent of the pod's CPU request) the autoscaler holds the fleet at. The autoscaler adds pods when average CPU exceeds this, removes them when below. 70 is a sensible default for CPU-bound services. Requires a CPU request to be meaningful — set `resources.cpu_request`."
                  }
                },
                "required": [
                  "min_replicas",
                  "max_replicas",
                  "target_cpu"
                ],
                "additionalProperties": false,
                "description": "Per-service horizontal autoscaling (HPA). When set, the service is managed by a HorizontalPodAutoscaler in stg + prod instead of a fixed replica count: the renderer omits the Deployment's `spec.replicas` and ArgoCD ignores it so the autoscaler owns the count. Requires `resources.cpu_request` (the CPU target is a percent of it). Mutually exclusive with the per-service `replicas` field — a service is either autoscaled or fixed-count, never both. Preview environments are not autoscaled (they run at the Kubernetes default of 1)."
              },
              "schedule": {
                "type": "object",
                "properties": {
                  "cron": {
                    "type": "string",
                    "description": "When the service runs: a standard 5-field cron expression (minute hour day-of-month month day-of-week, e.g. `0 2 * * *` = 02:00 daily) or one of `@hourly` / `@daily` / `@weekly` / `@monthly`. Evaluated in `timezone`."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "Timezone the cron expression is evaluated in: `UTC` or a Region/City IANA name (e.g. `America/Denver`). REQUIRED with no default, so the intended wall-clock time is always explicit. Zones with daylight-saving time shift the job's UTC firing time twice a year — use `UTC` for a DST-free schedule."
                  },
                  "concurrency": {
                    "type": "string",
                    "enum": [
                      "allow",
                      "forbid",
                      "replace"
                    ],
                    "default": "forbid",
                    "description": "What happens when the previous run is still active at the next tick: `forbid` (default) skips the new run, `allow` runs them concurrently, `replace` cancels the running job and starts a fresh one."
                  },
                  "active_deadline_seconds": {
                    "type": "integer",
                    "minimum": 60,
                    "maximum": 86400,
                    "default": 1800,
                    "description": "Wall-clock kill switch for a hung run, in seconds (default 1800 = 30 minutes; range 60–86400). A run that exceeds this is terminated and counted as failed. Size it to the job's worst-case healthy runtime plus headroom."
                  }
                },
                "required": [
                  "cron",
                  "timezone"
                ],
                "additionalProperties": false,
                "description": "Run this service on a schedule (as a Kubernetes CronJob) instead of as an always-on Deployment. The container's CMD is the job — it runs to completion and exits 0 on success. A scheduled service builds and configures like any other service (own Dockerfile, env vars, secrets, pod IAM) but cannot declare `port`, `expose`, `health_path`, `dns`, `gateway`, `scaling`, or `replicas`. Per environment the schedule is OFF until that env has a promoted image AND a nonzero `replicas.{env}` count (the same lever that activates Deployments on first promotion; set `replicas.{env}: 0` to pause). Per-PR preview environments never fire scheduled runs — the CronJob ships suspended there."
              },
              "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; use top-level `env_vars` (ConfigMap) for non-secret per-env values and `app_config_keys` (GitHub Environment Secrets → AWS Secrets Manager → pod env) 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."
              },
              "security": {
                "type": "object",
                "properties": {
                  "runAsUser": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "description": "Numeric UID the container runs as. Sets the pod's `runAsUser` and `runAsNonRoot: true`, for any language. Use this to pin a non-root UID when your image's default user differs from the platform default (65532). Must be a positive integer — 0 (root) is rejected. Omit to inherit the platform default: all languages run as 65532 (Node included, now that its base image runs as 65532). Make sure the UID can read the files and bind the ports your container needs, or the pod will crash-loop."
                  }
                },
                "additionalProperties": false,
                "description": "Per-service runtime security overrides. Today the only field is `runAsUser` (the non-root UID the container runs as). Omit the whole block to inherit the platform's per-language non-root default."
              }
            },
            "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": {
          "$ref": "#/definitions/ForgeYaml/properties/services/items/properties/language",
          "description": "PRIMARY language for the initial scaffold. Drives the placeholder Dockerfile, placeholder app file, dependency manifest, and language-specific CI defaults forge generates at scaffold time (per-language Dockerfile base image + env contract emit). 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. A monorepo MAY run additional services in OTHER languages — add those after scaffold via forge_modify_resources (action=add, resource_type=service), where you supply that service's own Dockerfile (which drives its build + runtime). Ignored on resource-only apps (services: []) but carried through round-trips."
        },
        "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",
              "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$",
              "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",
                    "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$",
                    "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 env var names whose values are secrets. Declare the key NAMES here; set the VALUES as GitHub Environment Secrets (repo Settings → Environments → <env> → Secrets), one value per environment. The app's sync-secrets workflow propagates the declared keys to the per-env AWS Secrets Manager secret `<app>/config`, and External Secrets Operator delivers them into the pod env — values never appear in git or in manifests. This is the channel for secrets and sensitive config; non-secret per-env config belongs in `env_vars` (ConfigMap) instead."
        },
        "env_vars": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/definitions/ForgeYaml/properties/services/items/properties/env/additionalProperties"
            },
            "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."
                  },
                  "distribution": {
                    "type": "boolean",
                    "description": "Mark this bucket as a release/distribution target. When true, the app's CI publish role for THIS bucket is scoped to the release prefix (see release_prefix) for object writes and drops delete permission — publishing is append/version, not delete. Leave unset for normal app-data buckets (CI keeps full Get/Put/Delete/List). Does not change the app's own runtime (pod) access. Default: false."
                  },
                  "release_per_env": {
                    "type": "boolean",
                    "description": "Only meaningful when distribution is true. When true, the CI publish role's trust is pinned PER deployment environment — the publish GitHub environment is `release-<env>` (release-stg, release-prod, …), matching a publisher that runs a SEPARATE protected environment per release channel (e.g. release-prod gated by a reviewer that release-stg is not). When false/unset (default), a SINGLE `release` environment is used across all envs. Set true only for per-channel publishers that need distinct per-env release gates."
                  },
                  "release_prefix": {
                    "type": "string",
                    "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]*$",
                    "description": "Key prefix the CI publish role is scoped to when distribution is true (default: \"releases\"). Objects are written under <prefix>/, e.g. releases/v1.2.3/app.zip. Ignored unless distribution is true."
                  },
                  "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. Materializes an S3 bucket-policy statement granting the named team (via its per-team PermissionSet) tiered access to THIS bucket (admin = read+write, readonly = read-only). Scoped to the single bucket; does not grant app-wide access."
                  },
                  "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. NOT YET SUPPORTED — per-resource per-user grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "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. NOT YET SUPPORTED — per-resource per-group grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "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. Declarable knobs: `versioning` (default on); `distribution` (+ optional `release_prefix`) scopes the app's CI publish role to a release prefix and drops delete on this bucket, for release/distribution targets. Per-bucket `team_grants` materializes a bucket-policy grant; `user_grants` / `group_grants` are not yet supported (deferred fast-follow, rejected at parse)."
              },
              "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. Materializes an SQS queue-policy statement granting the named team (via its per-team PermissionSet) tiered access to THIS queue (admin = send/receive/delete/manage, readonly = receive/inspect)."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-queue user grants. NOT YET SUPPORTED — per-resource per-user grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-queue Google-group grants. NOT YET SUPPORTED — per-resource per-group grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "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` materializes a queue-policy grant; `user_grants` / `group_grants` are not yet supported (deferred fast-follow, rejected at parse)."
              },
              "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": {
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "Per-topic team grants. List of {team, tier} pairs. Materializes an SNS topic-policy statement granting the named team's per-team PermissionSet tiered access to THIS topic (admin = Publish + Subscribe + describe; readonly = describe-only). Scoped to the single topic; does not grant app-wide access."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-topic user grants. NOT YET SUPPORTED — per-user resource grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants today."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-topic Google-group grants. NOT YET SUPPORTED — per-group resource grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants today."
                  },
                  "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 provisioning 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-topic `team_grants` materializes a topic-policy grant; per-resource `access`/`allowed_teams`/`allowed_apps`/`tags` 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. Materializes a DynamoDB resource-policy statement granting the named team (via its per-team PermissionSet) tiered access to THIS table and its indexes (admin = read+write item APIs, readonly = read item APIs)."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-table user grants. NOT YET SUPPORTED — per-resource per-user grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-table Google-group grants. NOT YET SUPPORTED — per-resource per-group grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "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` materializes a resource-policy grant; `user_grants` / `group_grants` are not yet supported (deferred fast-follow, rejected at parse)."
              },
              "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. Materializes an EventBridge bus-policy statement granting the named team (via its per-team PermissionSet) tiered access to THIS bus (admin = PutEvents + describe, readonly = describe)."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "Per-bus user grants. NOT YET SUPPORTED — per-resource per-user grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "Per-bus Google-group grants. NOT YET SUPPORTED — per-resource per-group grants are deferred to a fast-follow; declaring a non-empty list fails validation. Use team_grants for cross-team access today."
                  },
                  "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` materializes an event-bus-policy grant; `user_grants` / `group_grants` are not yet supported (deferred fast-follow, rejected at parse)."
              },
              "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)."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "NOT SUPPORTED for Step Functions — there is no AWS resource-based policy for a state machine, so cross-team access cannot be granted on the resource. A non-empty list fails validation. Share via a resource-policy-capable resource (s3/sqs/sns/dynamodb/eventbridge/kms) or a consuming app instead."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "NOT SUPPORTED for Step Functions (no resource-based policy). A non-empty list fails validation."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "NOT SUPPORTED for Step Functions (no resource-based policy). A non-empty list fails validation."
                  },
                  "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. Cross-team `team_grants` are NOT supported (no AWS resource-based policy for state machines)."
              },
              "propertyNames": {
                "minLength": 2,
                "maxLength": 16,
                "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
              },
              "description": "Step Functions state machines keyed by logical name (kebab-case, 2-16 chars — bounded by the synthesized SFN IAM role name vs IAM's 64-char cap; #1712). 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."
                  },
                  "schemas": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9_]*$"
                    },
                    "description": "Additional PostgreSQL schemas to pre-create in this database (default: none — the app uses `public`). Each named schema is created by the platform and owned by the app's migration role, so the managed migration job can create and own objects in it while the runtime service role gets USAGE only (no DDL). Use for frameworks that default to a named schema (e.g. EF Core `HasDefaultSchema(\"app\")`). The same schemas are created in per-PR (preview) databases. `public`, `pg_*`, and `information_schema` are reserved and rejected."
                  },
                  "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."
                  },
                  "app_permissions": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "SELECT",
                        "INSERT",
                        "UPDATE",
                        "DELETE",
                        "TRUNCATE",
                        "REFERENCES",
                        "TRIGGER"
                      ]
                    },
                    "minItems": 1,
                    "description": "Table-level privileges the app's RUNTIME (service) role gets on tables the migration role creates (default: SELECT, INSERT, UPDATE, DELETE). Narrow for least-privilege runtime — e.g. [SELECT, INSERT] for an append-only event store. Applied to BOTH the per-env database AND per-PR preview databases, so preview always matches stg/prod. Does not affect the migration role (it owns the schema) or team_grants tiers. Allowed verbs: SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER (uppercase)."
                  },
                  "migrations": {
                    "anyOf": [
                      {
                        "type": "boolean",
                        "const": false
                      },
                      {
                        "type": "object",
                        "properties": {
                          "command": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "minLength": 1
                            },
                            "minItems": 1,
                            "description": "Migration command argv array. Platform default for Node apps is [\"infra/database/migrations/migrate.sh\"] (Liquibase — the scaffolded wrapper mints an RDS IAM token and runs `liquibase update`). 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. Tool-agnostic: swap to node-pg-migrate/Prisma/Atlas/etc. by editing the command + the Dockerfile install layer."
                          },
                          "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."
                      }
                    ],
                    "description": "Managed database migrations. Default ON: when omitted, forge runs a Liquibase migration Job (re-using the app image, bootstrapping schema before app pods start) and bakes the Liquibase/JRE layer into the app Dockerfile. Set `false` to turn managed migrations OFF — forge then skips BOTH the migrate Job AND the Liquibase/JRE Dockerfile layer (use this when your app manages its own schema, or has none). Provide an object ({ command, runs_on }) to customize the migration command or which env overlays run it."
                  },
                  "seed": {
                    "type": "object",
                    "properties": {
                      "command": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "minLength": 1
                        },
                        "minItems": 1,
                        "description": "Fixture-seed command argv array (e.g. [\"npm\", \"run\", \"seed:preview\"]). Each entry is a literal string — no shell expansion. The Job re-uses the app image and runs this as the app runtime role against the PREVIEW database only, after the app Deployment is healthy. Make it idempotent (INSERT ... ON CONFLICT) — ArgoCD re-syncs re-run it."
                      },
                      "runs_on": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "enum": [
                            "prev"
                          ]
                        },
                        "minItems": 1,
                        "description": "Fixture seeds are preview-only by design (Decision 5 — fake data must never reach staging or production). Only \"prev\" is accepted; \"stg\"/\"prod\" are REJECTED at parse time (fail-loud). The field is optional and defaults to [\"prev\"]; it exists mainly to document intent, since \"prev\" is the only valid value."
                      }
                    },
                    "required": [
                      "command"
                    ],
                    "additionalProperties": false,
                    "description": "Preview-only fixture-data seed runner. When set, the renderer emits a `seed-job.yaml` ArgoCD Sync hook into the PREVIEW overlay only, running `command` as the app runtime role after the Deployment is healthy."
                  },
                  "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."
              },
              "description": "Per-app PostgreSQL databases keyed by logical name. The key is a bare lowercase PostgreSQL identifier (no hyphens) — it becomes the per-env DB name `{app}_{key}` and the per-PR DB name `{app}_pr_{N}_{key}`. `app_name` length + key length must be ≤ 52 (so the per-PR name fits PostgreSQL's 63-char identifier limit)."
            },
            "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,
                  "pattern": "^(?:\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2}))?$",
                  "description": "RFC3339/ISO-8601 expiry timestamp for the namespace's API key — must include a time component, e.g. `2027-04-01T00:00:00Z` (a bare date like `2027-04-01` is rejected). 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."
                  },
                  "team_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/team_grants/items"
                    },
                    "description": "NOT SUPPORTED for Firehose — there is no AWS resource-based policy for a delivery stream, so cross-team access cannot be granted on the resource. A non-empty list fails validation. Share the destination S3 bucket's `team_grants`, or use a resource-policy-capable resource (s3/sqs/sns/dynamodb/eventbridge/kms) instead."
                  },
                  "user_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/user_grants/items"
                    },
                    "description": "NOT SUPPORTED for Firehose (no resource-based policy). A non-empty list fails validation."
                  },
                  "group_grants": {
                    "type": "array",
                    "items": {
                      "$ref": "#/definitions/ForgeYaml/properties/resources/properties/s3/additionalProperties/properties/group_grants/items"
                    },
                    "description": "NOT SUPPORTED for Firehose (no resource-based policy). A non-empty list fails validation."
                  },
                  "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. Cross-team `team_grants` are NOT supported (no AWS resource-based policy for delivery streams)."
              },
              "propertyNames": {
                "minLength": 2,
                "maxLength": 16,
                "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
              },
              "description": "Kinesis Data Firehose delivery streams keyed by logical name (kebab-case, 2-16 chars — bounded by the synthesized Firehose IAM role name vs IAM's 64-char cap; #1712). 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"
                    },
                    "propertyNames": {
                      "pattern": "^[a-zA-Z][a-zA-Z0-9_:.\\-]*$"
                    },
                    "description": "Optional pass-through tags applied to the KMS key. Keys must start with a letter and contain only letters, digits, underscores, colons, dots, and dashes (matching AWS tag key constraints). 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. Materializes a KMS key-policy statement granting the named team (via its per-team PermissionSet) tiered access to THIS key. admin = full data-plane (Encrypt, Decrypt, GenerateDataKey*, ReEncrypt*, DescribeKey) — admin tier on a CMK IMPLIES Decrypt; readonly = Decrypt + DescribeKey (consume-only)."
                      }
                    },
                    "additionalProperties": false,
                    "description": "Per-key access block. `team_grants` materializes a KMS key-policy grant today (admin tier implies Decrypt). Per-user/per-group KMS grants are not supported (the strict shape accepts only team_grants). 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."
        },
        "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`."
            },
            "stale_days": {
              "type": "integer",
              "minimum": 1,
              "description": "Days of PR inactivity before the scaffolded stale-pr workflow applies the `stale` label (default 60 when omitted). To turn the workflow off entirely, omit `preview` or set `preview.enabled: false`."
            },
            "close_grace_days": {
              "type": "integer",
              "minimum": 1,
              "description": "Days after the `stale` label is applied before the PR is auto-closed (default 7 when omitted, i.e. close at 67 days inactive). Auto-close fires the existing per-PR teardown via the `pull_request: closed` trigger."
            },
            "max_concurrent": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12,
              "description": "Max per-PR preview environments this app may have provisioned at once (default 5 when omitted). Each preview holds a per-PR database capped at 250 connections on the SHARED preview Aurora cluster (~3000 total), so raising this trades fleet headroom — 12 (the hard ceiling) would let this app alone exhaust the cluster. An over-cap PR provisions nothing until a slot frees (close/merge another PR), an SRE adds the `sre-override-preview-cap` label, or this value is raised. Enforced pre-plan by the reusable terraform workflow (B-Cap #1370)."
            }
          },
          "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": {
          "anyOf": [
            {
              "type": "string",
              "const": "none"
            },
            {
              "type": "object",
              "properties": {
                "access_mode": {
                  "type": "string",
                  "enum": [
                    "restricted",
                    "all"
                  ],
                  "default": "restricted",
                  "description": "Access policy (default: \"restricted\"). \"restricted\" allows only members granted access via AVP Cedar policies; \"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."
                },
                "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."
                }
              },
              "additionalProperties": false
            }
          ],
          "description": "Optional Auth Service integration. `\"none\"` = no-auth app (no authentication; served off Istio via an ALB — `gateway: \"istio\"` is rejected since ext_authz cannot be disabled per-app. No-auth internal apps are VPN-only; no-auth public apps are served via the per-env CloudFront edge to a private ALB, on stg/prod only — prev has no edge; per-host internet activation is gated, so confirm in #forge-testing before depending on public exposure). Omitted = Forge applies the closed-restricted safe default at create time — single `admins` tier on `app-{app}-admins@conservice.com`. Object = full auth config to override tier shape, access mode, or seed-member lists."
        },
        "authz": {
          "type": "object",
          "properties": {
            "initial_grants": {
              "anyOf": [
                {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "format": "email"
                  }
                },
                {
                  "type": "object",
                  "properties": {
                    "prev": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "propertyNames": {
                        "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
                      }
                    },
                    "stg": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "$ref": "#/definitions/ForgeYaml/properties/authz/properties/initial_grants/anyOf/1/properties/prev/additionalProperties/items"
                        }
                      },
                      "propertyNames": {
                        "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
                      }
                    },
                    "prod": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "$ref": "#/definitions/ForgeYaml/properties/authz/properties/initial_grants/anyOf/1/properties/prev/additionalProperties/items"
                        }
                      },
                      "propertyNames": {
                        "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              ],
              "default": [],
              "description": "Grants seeded at scaffold time (default: []). Two accepted shapes:\n  • Legacy: `string[]` of @conservice.com emails → each granted `access` in every env (prev/stg/prod).\n  • Per-env map: `{ prev: { admin: ['team-ai'], access: ['x@conservice.com'] }, stg: {...}, prod: {...} }` — keys are role names (access/admin/custom), values are principals (team slug `team-<slug>` or @conservice.com email).\nOne-shot — NOT re-applied on subsequent forge runs. auth-portal owns ongoing grant management. Edits here after scaffold are inert."
            },
            "roles": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 40,
                    "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
                  },
                  "description": {
                    "type": "string",
                    "minLength": 5,
                    "maxLength": 200,
                    "description": "Human-readable role description shown in auth-portal grant UI"
                  }
                },
                "required": [
                  "name",
                  "description"
                ],
                "additionalProperties": false
              },
              "default": [],
              "description": "Custom app-specific roles beyond the built-in `access` role. The names `access`, `admin`, and `ping` are reserved platform actions and cannot be reused here: `access` is the default role granted to individuals and teams; `admin` is group-scoped (used for auth-portal admin gating) and is NOT an individually-grantable role — to give a single user elevated access, declare a custom role here and grant that. Each entry creates a Cedar action in the AVP schema and a named policy template (`name/{role-name}`) in each env's policy store at scaffold time. auth-portal automatically discovers named templates and surfaces them in the grant management UI. Examples: editor, viewer, billing-admin, approver."
            }
          },
          "additionalProperties": false,
          "description": "Optional AVP (AWS Verified Permissions) gateway authorization. When present, Forge registers the app as a `Conservice::Application` entity in each env's AVP Policy Store and seeds the owning-team grant plus any `initial_grants` as template-linked policies (SEEDED ONCE at first scaffold). Meaningful when any service has `gateway: \"istio\"` — the Istio ext_authz filter calls AVP `IsAuthorized` on every request; without a grant, users get 403. auth-portal owns ongoing grant management after the one-time seed."
        },
        "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": {
              "anyOf": [
                {
                  "type": "integer",
                  "minimum": 0
                },
                {
                  "type": "string",
                  "pattern": "^\\d+$"
                }
              ]
            },
            "prod": {
              "$ref": "#/definitions/ForgeYaml/properties/replicas/properties/stg"
            }
          },
          "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: a non-negative integer or a non-negative numeric string (Kargo writes the count as a quoted scalar); both are normalized to a number."
        },
        "canary": {
          "type": "boolean",
          "description": "DEPRECATED — use `render_channel: canary` instead. Per-app canary opt-in. When `true`, the app's emitted terraform.yaml pins the canary render version (advance-on-every-publish) instead of the general render version (post-48h-bake-promotion). Reserved for SRE-owned canary apps (`forge-canary-*`); general apps omit or set `false` (default). Retained as a backward-compatible alias: when both are set, `render_channel` wins."
        },
        "render_channel": {
          "type": "string",
          "enum": [
            "general",
            "canary"
          ],
          "description": "Render channel for this app. `general` (default) runs the stable, post-bake-promoted render version; `canary` runs the advance-on-publish render version and is reserved for SRE-owned `forge-canary-*` apps. Omit for general apps. Supersedes the deprecated `canary` boolean (when both are set, `render_channel` wins)."
        },
        "app_kind": {
          "type": "string",
          "enum": [
            "web-service",
            "worker",
            "cron",
            "batch"
          ],
          "description": "App kind — `web-service` | `worker` | `cron` | `batch`. Drives the Datadog Monitor Pack catalog gate (5xx for web-service, queue lag for worker, etc.) — and nothing else. `cron` and `batch` affect monitor classification ONLY: this field does not schedule anything. To actually run a service on a schedule, declare `services[].schedule` on that service — it then deploys as a Kubernetes CronJob (services without `schedule` run as regular always-on Deployments; one-shot batch execution is not yet supported). Optional at the schema level; required at the module call site when monitors are emitted (the module defaults are applied by forge-render, not by this schema)."
        },
        "slo_tier": {
          "type": "string",
          "enum": [
            "tier-1",
            "tier-2",
            "tier-3"
          ],
          "description": "SLO tier — `tier-1` (full catalog incl. P3s) | `tier-2` (default, drops most-sensitive P3s) | `tier-3` (P1/P2 only, internal-tool profile). Catalog-membership lever, NOT a routing lever (apps that need stg-paging declare a `monitors.routing` override). Optional at the schema level; downstream module defaults to tier-2 when unset."
        },
        "monitors": {
          "type": "object",
          "properties": {
            "pagerduty_service": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64,
              "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$",
              "description": "PagerDuty service name. Routes prod monitors via `@pagerduty-${name}`. When `pagerduty_create_new: true`, forge auto-PRs a `pagerduty_service` resource against infra-datadog-platform; otherwise forge_preflight validates the name exists. OPTIONAL (idp#1260 Build B): omit only with an explicit `routing.prod` (e.g. `routing: { prod: [\"datadog-event\"] }`) — Datadog-event routing fires the monitor into Datadog with no external page."
            },
            "pagerduty_create_new": {
              "type": "boolean",
              "description": "Whether forge should auto-PR a new PD service (true) or reuse an existing one (false / omitted). When true, `pagerduty_escalation_policy` is required. The auto-PR is non-blocking — the configure flow returns the PR URL immediately and `activateMonitorsOnPlatformPRMerge` flips the module's enabled gate after merge."
            },
            "pagerduty_escalation_policy": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64,
              "description": "PagerDuty escalation-policy name. Required when `pagerduty_create_new: true` — the auto-PR references the EP via a `data \"pagerduty_escalation_policy\"` block (forge does NOT create EPs; they remain team-owned in the PD UI)."
            },
            "google_chat": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 64,
                  "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
                  "description": "Google Chat webhook name (kebab-case). Datadog routes to this as `@webhook-${name}`; must match a `datadog_webhook` resource in infra-datadog-platform's `google-chat/` folder (existing or auto-PRed)."
                },
                "space_id": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 64,
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Google Chat space ID — the `{SPACE_ID}` segment in `https://chat.googleapis.com/v1/spaces/{SPACE_ID}/messages?key=...&token=...`. Plaintext by design (useless without the org-level key in AWS Secrets Manager)."
                },
                "token": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 128,
                  "pattern": "^[A-Za-z0-9_-]+$",
                  "description": "Per-webhook token — the `{TOKEN}` segment in the GC webhook URL. Plaintext by design (defense-in-depth: token+space pair is useless without the org-level API key)."
                }
              },
              "required": [
                "name",
                "space_id",
                "token"
              ],
              "additionalProperties": false,
              "description": "Google Chat webhook config — name + space_id + token. The 3-tuple decomposes into Datadog's GC webhook URL. The org-level API key lives in AWS Secrets Manager; the per-team space_id + token are plaintext here. OPTIONAL (idp#1260 Build B): omit only with an explicit `routing.stg` (e.g. `routing: { stg: [\"datadog-event\"] }`) — Datadog-event routing fires the monitor into Datadog with no external webhook."
            },
            "dev_channel": {
              "$ref": "#/definitions/ForgeYaml/properties/domain",
              "description": "Optional prev-env opt-in. When set, prev monitors route to `@webhook-${dev_channel}` (typically the scaffolding dev's personal/debug GC webhook). When omitted, prev emits zero monitors (prev silent by default)."
            },
            "routing": {
              "type": "object",
              "additionalProperties": {
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1
                },
                "minItems": 1
              },
              "propertyNames": {
                "enum": [
                  "prev",
                  "stg",
                  "prod"
                ]
              },
              "description": "Optional explicit per-env routing override. When set for an env, REPLACES the default matrix (prod→PD, stg→GC, prev→dev_channel-or-silent) for that env. Envs not listed fall back to default. The override is the ONLY surface where multi-handle destinations are expressed (e.g. tier-1 paging on stg)."
            },
            "extra": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "Human-readable monitor name. Appears in the Datadog UI and in alert message previews."
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "metric alert",
                      "query alert",
                      "service check",
                      "event alert",
                      "event-v2 alert",
                      "log alert",
                      "process alert",
                      "trace-analytics alert",
                      "slo alert",
                      "rum alert",
                      "ci-pipelines alert",
                      "ci-tests alert",
                      "error-tracking alert",
                      "audit alert",
                      "database-monitoring alert"
                    ],
                    "description": "Datadog monitor type literal — `metric alert`, `query alert`, `log alert`, etc."
                  },
                  "query": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Raw Datadog query string. Authors are responsible for `env:${var.env}` / `service:${var.app_name}` scoping; the module emits the query verbatim."
                  },
                  "priority": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5,
                    "description": "Priority 1-5 (1 = highest, page-worthy P1; 5 = lowest, FYI). Stamped on the monitor as the native DD priority attribute AND duplicated into the `priority:P{n}` tag so filter views surface it."
                  },
                  "category": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 64,
                    "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$",
                    "description": "Category slug (kebab-case). Tagged on the monitor as `category:${value}` for filter-view grouping. Devs SHOULD reuse a default-catalog category name (`apm`, `web-service`, `worker`, `database`, `dlq`, `bedrock`, `log`, `pod-health`, `saturation`) when the extra is conceptually a peer; pick an app-specific slug otherwise."
                  },
                  "thresholds": {
                    "type": "object",
                    "properties": {
                      "critical": {
                        "type": "number",
                        "description": "Critical threshold — fires the monitor when the query value crosses this number."
                      },
                      "warning": {
                        "type": "number",
                        "description": "Warning threshold — soft-state when crossed; does not page."
                      },
                      "critical_recovery": {
                        "type": "number",
                        "description": "Recovery threshold — used to avoid flap: set below `critical` so the monitor only un-fires after the signal has clearly subsided."
                      }
                    },
                    "additionalProperties": false,
                    "description": "Threshold values used by Datadog's `monitor_thresholds {}` block."
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "minLength": 1
                    },
                    "description": "Optional additional tags appended to the module's common-tag set. Use for app-specific filtering (e.g. `subsystem:invoice-render`)."
                  }
                },
                "required": [
                  "name",
                  "type",
                  "query",
                  "priority",
                  "category",
                  "thresholds"
                ],
                "additionalProperties": false
              },
              "description": "Optional team-supplied extras. Passed through verbatim to the Terraform module's `var.extra_monitors`; unioned with the default catalog at emit time."
            },
            "activated": {
              "type": "boolean",
              "description": "Managed by forge — do not set by hand. When true, the app's Datadog monitors are live; when false or omitted they are rendered but stay suppressed (no alerts) until their notification targets exist. Forge turns this on automatically once the platform-side monitor resources are in place."
            },
            "activated_by": {
              "type": "string",
              "const": "forge",
              "description": "Managed by forge — do not set by hand. Provenance marker that forge stamps alongside `activated: true` once the monitors' notification targets exist (infra-forge#1753). forge-render uses it to distinguish a legitimate forge activation from a premature hand-set."
            },
            "auto_activate": {
              "type": "boolean",
              "description": "Opt in to hands-off activation. When true, forge auto-merges the follow-up change that turns the monitors on once their notification targets exist; when false or omitted, that change is opened as a pull request for you to review and merge."
            }
          },
          "additionalProperties": false,
          "description": "Optional Datadog Monitor Pack configuration. PD service / EP / GC webhook details, optional `dev_channel` for prev-env opt-in routing, `routing` overrides, and team-supplied `extra` monitors. Omit the block to scaffold without monitors; configure later via `forge_configure_monitors`. `forge_preflight` warns (non-blocking) when stg/prod-targeting apps lack this block."
        },
        "observability": {
          "type": "object",
          "properties": {
            "trace_sample_rate_prod": {
              "type": "number",
              "minimum": 0,
              "maximum": 1,
              "description": "Datadog APM trace sample rate for the PRODUCTION overlay only (0.0–1.0). Optional opt-in cost control; omit to use the Datadog default. prev/stg always sample at 1.0. Sets DD_TRACE_SAMPLE_RATE on the prod overlay."
            }
          },
          "additionalProperties": false,
          "description": "Optional per-app observability tuning. Dev-owned; not machine-written."
        },
        "publish": {
          "type": "object",
          "properties": {
            "nuget": {
              "type": "object",
              "properties": {
                "namespace": {
                  "type": "string",
                  "minLength": 1,
                  "pattern": "^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?)+$",
                  "description": "Dotted NuGet package-ID STEM this app publishes under (e.g. `Conservice.Billing`). Must start with `Conservice.` — the CodeArtifact resource policy (iap#1350) only grants publishing to `Conservice.*`. This is the STEM, NOT a glob: the conservice-app-baseline module appends `.*` itself, so do not include a trailing `.*`. Declaring `publish.nuget` opts the app's CI role into the package-publisher shape (publishes_packages + package_namespace) and auto-provisions the protected, web-identity-trusted `release` environment."
                }
              },
              "required": [
                "namespace"
              ],
              "additionalProperties": false,
              "description": "NuGet package-publishing intent. Present → the app's CI role is granted publish to the declared `Conservice.*` namespace (conservice-app-baseline v10.22.0+)."
            }
          },
          "additionalProperties": false,
          "description": "Optional package-publishing intent. Today only `nuget` is modeled. Declaring `publish.nuget` opts the app's CI role into the package-publisher shape (publishes_packages + package_namespace, conservice-app-baseline v10.22.0+) and auto-provisions the protected, web-identity-trusted `release` environment — independent of any S3 `distribution: true` bucket."
        },
        "dashboard": {
          "type": "object",
          "properties": {},
          "additionalProperties": false,
          "description": "Optional Datadog dashboard shell. Write `dashboard: {}` to opt in — forge-render emits one create-only `datadog_dashboard` shell per env (stg + prod only) on the curated name `[${env}] ${app} — overview`; you build the widgets in the Datadog UI and re-renders never revert them. Omit for no dashboard. v1 takes no sub-fields (the name is platform-curated)."
        },
        "github": {
          "type": "object",
          "properties": {
            "read": {
              "type": "boolean",
              "description": "Grant this app READ access to its OWN TEAM's forge-created repos (the open set — restricted `infra-*` repos excluded) by setting `read: true`. Provisions a Pod-Identity-authenticated read-token mint (preview + staging + production pods). Self-serve, keyless, no approval. Fleet-wide read over the whole open set is a SEPARATE, SRE-classified tier (CROSS_FLEET_READ_CONSUMERS) — NOT declarable here. Breadth + permission scope are server-side and platform-managed, resolved at mint from the verified caller."
            },
            "write": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": [
                  "code",
                  "issues",
                  "boards"
                ]
              },
              "minItems": 1,
              "maxItems": 1,
              "description": "Grant this app a keyless WRITE capability over your OWN TEAM's forge-created repos (∩ open set) — declare the ACTION, never the target. `issues` = issues:write only (self-serve, LIVE). `code` = contents:write + pull_requests:write (open-PR/push; merge stays human-gated; self-serve, LIVE — the shared `conservice-app-write` App ceiling now includes pull_requests:write, closing the earlier mint-422 gap). `boards` = org-wide GitHub Projects write — SRE-gated (deny-all today); declaring it records INTENT only and activates only on an explicit SRE grant. Write includes read on the same repos (GitHub leveled perms). Mint is available to preview + staging + production pods (callers whose env can't be derived 403 fail-closed before the capability gate). Single-element today (one lane per app); multi-lane support ships later. Targets + breadth + permission scope are server-side and platform-managed, resolved from the verified caller — never from this file."
            },
            "projects": {
              "type": "string",
              "enum": [
                "read",
                "write"
              ],
              "description": "Grant this app org GitHub Projects access. `read` = org-wide board READ (self-serve; read cannot mutate any board). `write` = org-wide board WRITE (records intent only; activates only on an explicit SRE grant). Declare the access level, never a target — breadth is server-side and platform-managed."
            }
          },
          "additionalProperties": false,
          "description": "Optional GitHub access capability — the app mints a short-lived token off its Pod Identity at runtime (no standing credential in the pod). `read: true` grants READ over your OWN TEAM's forge-created repos (open set; restricted `infra-*` excluded) — self-serve, keyless, no approval. Fleet-wide (whole open set) read is a SEPARATE SRE-classified tier you cannot self-declare here. `write: [<lane>]` grants a keyless WRITE capability over the SAME own-team ∩ open-set repos — declare the ACTION, never the target: `issues` = issues:write (self-serve, LIVE); `code` = contents+pull_requests:write (open-PR/push; merge stays human-gated; self-serve, LIVE); `boards` = org-wide GitHub Projects write — SRE-gated (deny-all today), declaring it records INTENT only. `write` includes read on the same repos (GitHub leveled perms). Exactly one write lane per app today (multi-lane support ships later). Mint is available to preview + staging + production pods (callers whose env can't be derived 403 fail-closed before the capability gate). Breadth + permission scope + repo targets are server-side and platform-managed, resolved from the verified caller — never from this file. Control-plane only — emits no terraform. Omit for no GitHub access."
        }
      },
      "required": [
        "forge_version",
        "app_name",
        "team"
      ],
      "additionalProperties": false
    }
  },
  "$schema": "http://json-schema.org/draft-07/schema#"
}
