forge.yaml Platform Schema (v1)
URL major version
v1is the first published forge.yaml schema contract. Future breaking schema changes will publish tov2,v3, etc. — old majors stay live forever so apps pinned tov1keep validating afterv2ships. The forge.yaml schema-contract value users put in theirforge_version:field is a separate, finer-grained version (semver inside the major) — see § Schema versioning. Machine-readable JSON Schema:forge.yaml.schema.json— drop into.vscode/settings.jsonyaml.schemasfor IDE autocomplete + validation.
Contents
- What this is
- Quick start — minimal forge.yaml
- App repo layout
- Top-level fields
- Language
- Services
- DNS
- App config keys
- Env vars
- Resources
- Cross-app access (per-resource policy + consumes)
- Auth
- Environment opt-out (disabled_envs)
- Preview
- Image tags
- Replicas
- Canary
- Reserved naming
- Naming patterns (resolved at scaffold)
- Schema versioning
- Where to file requests
- Cross-references
What this is
forge.yaml is the declarative spec for an app on Conservice's greenfield platform. It lives at infra/forge.yaml in your app repo. Forge reads it and renders all the underlying infrastructure (Terraform for AWS resources, Kustomize for K8s manifests, GitHub Actions workflows, ArgoCD apps, Kargo pipelines, Workspace + Identity Center bindings).
You don't write Terraform. You write forge.yaml. Forge takes it from there.
This document is the complete schema reference for forge.yaml — every field, allowed value, naming constraint, and validation rule the schema enforces.
Quick start — minimal forge.yaml
forge_version: 1.0.0
app_name: my-app
team: sre
language: typescript
services:
- name: api
port: 8080
# no `expose:` → ClusterIP-only (in-cluster HTTP, sister-service callable). Add
# `expose: internal` for VPN-only routing or `expose: public` for internet-facing.
health_path: /health
dockerfile: Dockerfile
context: .
That's enough to scaffold an app: an in-cluster API service (no external or VPN exposure — sister services can call it via mesh) deploying to all platform envs (prev/stg/prod). Add expose: internal to the service for VPN-only routing or expose: public for internet-facing. Add resource declarations under resources: to get S3 buckets, SQS queues, databases, etc. Use disabled_envs: to opt an app out of specific envs (see § Environment opt-out).
Resource-only apps (no runtime code — just S3 + DDB + queues) are allowed: set services: [] and omit language. See § Language.
App repo layout
forge.yaml is the source of truth, but the rendered output of the platform lives alongside it in your app repo. Knowing which paths are dev-editable vs CI-generated matters when you read a PR diff or wonder why your hand-edit "disappeared."
forge.yaml does not configure GitHub repository visibility or team access. Forge-created app repos are always private; repo creation and access grants are handled by Forge outside this schema. Do not add a visibility key to forge.yaml.
{name}/
└── infra/
├── forge.yaml # SOURCE OF TRUTH — dev edits this
└── deploy/
├── patches/{env}/ # dev-editable escape hatch (kustomize patches)
│ └── *.yaml # strategic merge or JSON6902
├── rendered/{env}/ # CI-generated, do NOT edit
│ ├── kustomization.yaml # plain rendered manifests
│ └── *.yaml # ArgoCD reads from here
└── overlays/{env}/ # legacy, being deprecated
| Path | Who edits | What it does |
|---|---|---|
infra/forge.yaml | dev (source of truth) | The declarative spec this document describes. Everything below is rendered from it. |
infra/deploy/patches/{env}/*.yaml | dev (escape hatch) | Kustomize patches layered on top of the base render. Use when you need a hand-edit not yet expressible in the schema (env vars / labels / annotations the platform hasn't surfaced as a field yet). Each patch represents a tracked schema-gap. |
infra/deploy/rendered/{env}/ | CI (forge-render-and-commit.yaml) | Plain rendered Kubernetes manifests committed back to the PR branch by the platform's GitHub App on every push. ArgoCD reads from here. Dev hand-edits to this directory get overwritten on the next push. |
infra/deploy/overlays/{env}/ | legacy, being deleted | Pre-2026-05 scaffold output. Apps that haven't migrated still have it; the directory disappears once all apps cut over to the Rendered Manifests Pattern (no action needed on your side). |
The schema itself doesn't change under this layout — forge-render still consumes forge.yaml from infra/forge.yaml, validates it against the JSON Schema linked at the top of this page, and emits the rendered output. The only thing that's new is where rendered files land in the repo and who maintains them. Direct forge.yaml edits in a feature branch + PR are the supported flow: CI re-renders on every push, so the PR diff shows BOTH the source change AND the rendered effect before merge.
Top-level fields
The root object accepts these keys (and rejects unknowns — forge_version: 1.0.0 typo'd as forgeVersion will fail at validation, not silently default). Strict-key validation applies recursively: every nested object below also rejects unknown keys.
| Field | Type | Required | Notes |
|---|---|---|---|
forge_version | string | yes | Schema version. X.Y or X.Y.Z semver. |
app_name | string | yes | Kebab-case (lowercase + digits + hyphens, start with letter, end with letter/digit), 3-40 chars per the Zod schema. Practical ceiling: ~25 chars to leave headroom for derived identifiers (the PostgreSQL login role aws-{app}-db-{tier} is the binding identifier). Reserved prefixes are rejected — see Reserved app-name prefixes. |
team | string | yes | Owning team's kebab-case slug. Resolves to team-{team}@conservice.com for membership and is stamped on every resource as the team AWS tag. AWS access is keyed by team via aws-team-{team}-{tier}; there is no per-app aws-{app}-admin/readonly group anymore. Must match an entry in forge's ALLOWED_TEAMS list. |
domain | string | no | Business domain (e.g., billing, identity, platform). Used for AWS resource tagging only — does not affect provisioning shape. |
portfolio | string | no | Portfolio grouping for finance/cost-allocation rollups. Used for AWS resource tagging. Falls back to team when unset. |
services | array | no | Containers Forge builds and deploys. Defaults to [] (resource-only app). When non-empty, language is required. See § Services. |
language | enum | conditional | Runtime/language for the app's services. Required when services is non-empty. Closed enum: typescript, javascript, python, go, csharp, java, rust. See § Language. |
dns | object | no | DNS exposure config (primary zone + hostname + optional sister aliases). See § DNS. |
app_config_keys | array of strings | no | UPPER_SNAKE_CASE env var names for dev-managed secrets. See § App config keys. |
resources | object | no | AWS resources to provision. See § Resources. |
consumes | object | no | Cross-app resource and service consume declarations. See § Cross-app access. |
auth | object | no | Auth Service integration — tier groups, access mode, seed members. See § Auth. |
env_vars | object | no | Static per-env config injected into the app ConfigMap. See § Env vars. |
disabled_envs | array of enums | no | Opt the app out of specific platform envs. See § Environment opt-out. |
preview | object | no | Preview-environment opt-in. See § Preview. |
image_tags | object | no | Per-env image tags written by Kargo after promotion. See § Image tags. |
replicas | object | no | Per-env replica counts (source of truth for HPA). See § Replicas. |
canary | boolean | no | Opt into the advance-on-every-publish forge-render pin channel. Default: false. |
environments | object | no | DEPRECATED. Still parses for back-compat; the renderer ignores it. Use disabled_envs: instead. See § Environment opt-out. |
Language
The language field declares the runtime/ecosystem the app's services are written in. Forge uses it to pick the right Dockerfile base image and emit the matching per-language env contract.
language: typescript
| Value | Notes |
|---|---|
typescript | Per-language emitter shipped. |
javascript | Per-language emitter shipped. |
python | Per-language emitter shipped. |
go | Per-language emitter shipped. |
csharp | Per-language emitter shipped. |
java | Per-language emitter shipped. |
rust | Per-language emitter shipped. |
Lowercase, no version suffix — the value identifies the ecosystem, not the specific runtime version. language is required when services is non-empty and accepted-but-ignored when services: [] (resource-only apps have no code to language-tag, but round-tripping the field is fine).
Adding a new language requires both a schema-enum bump and a corresponding per-language emitter in forge-render — file a platform request.
Services
Every app may declare zero or more services. Forge emits three structurally distinct shapes per service depending on port: and expose::
port: set? | expose: value | Forge emits |
|---|---|---|
| no | (n/a) | Just a Deployment (worker) |
| yes | (absent) | Deployment + ClusterIP Service (in-cluster HTTP, mesh-callable) |
| yes | internal | Deployment + ClusterIP + HTTPRoute on internal-gateway (VPN-only) |
| yes | public | Deployment + ClusterIP + HTTPRoute on external-gateway (internet) |
services:
- name: api
port: 8080
expose: public # internet-facing via external-gateway
health_path: /health
dockerfile: services/api/Dockerfile
context: services/api/
replicas: 2
env:
LOG_LEVEL: info
resources:
cpu_request: "100m"
memory_request: "256Mi"
memory_limit: "512Mi"
- name: worker # no port + no expose → worker-only
dockerfile: services/worker/Dockerfile
context: services/worker/
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | At least 1 char. Used as the K8s service name + ECR repo suffix. Must be unique within services[]. |
port | integer | conditional | 1-65535. Required when expose: is set. Omitted = worker (Deployment only). |
expose | enum | no | "public" (external-gateway, internet) or "internal" (internal-gateway, VPN). Omitted = ClusterIP-only (in-cluster HTTP, no gateway route). v1 cap: at most ONE service per app may have expose: set — mixed-mode (1 public + 1 internal) is deferred until per-service hostname routing lands. |
health_path | string | no | HTTP path the K8s liveness/readiness probes hit. Probes are only emitted when BOTH health_path and port are set. |
dockerfile | string | no | Path to Dockerfile, relative to context. Default: Dockerfile. Override for monorepos with per-service Dockerfiles like Dockerfile.worker. |
context | string | no | Docker build context, relative to repo root. Default: .. |
replicas | integer | no | Non-negative. Per-env defaults (prev=1, stg/prod=2) are applied by forge-render at emit time, not by the schema. Set 0 to suspend deployment without removing the resources. |
env | object (string→string) | no | Static env vars baked into the Deployment manifest (UPPER_SNAKE_CASE keys). Reserved prefixes (see Reserved env-var name prefixes) cannot be shadowed here. |
resources | object | no | K8s resource requests/limits. Keys: cpu_request, memory_request, memory_limit. All three must be set together when the block is present — per-field fallback isn't supported. Omit the block entirely for platform defaults (100m / 256Mi / 512Mi). |
DNS
Controls the app's public hostname, which Gateway-API listener routes to it, and any sister hostnames on additional zones.
dns:
required: true
zone: conservice.ai
hostname: my-app
aliases:
- zone: conservice.cloud
hostname: my-app.conservice.cloud
| Field | Type | Required | Notes |
|---|---|---|---|
required | bool | no | Whether DNS records get created. Default: false. Only a service with expose: set can satisfy required: true. |
zone | enum | no | Primary zone. One of conservice.ai, conservice.cloud, capturis.ai, svc.conservice.ai. conservice.ai is the documented default for new internet-facing apps; the others 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 conservice.ai with services[].expose: internal for VPN-only apps. |
hostname | string | no | Optional explicit hostname override (e.g. rates-prod.conservice.ai). Default: {app_name}.{zone}. |
aliases | array of objects | no | Sister hostnames on additional zones for the same workload (max 5). Each entry: { zone, hostname }. 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. |
Per-entry alias rules (enforced at parse time):
aliases[].zonemust differ from the primarydns.zone(aliases are sister hostnames on a different TLD).aliases[].zonecannot besvc.conservice.ai.aliases[].zonevalues must be distinct across all entries — two aliases on the same zone would collide on the same Gateway listener.aliases[].hostnamemust end with its declaredzone(e.g.auth.conservice.cloudforzone: conservice.cloud).aliasesrequireszoneto be set (you can't alias-only without a primary).
Public vs. internal exposure is per-service — set expose: public or expose: internal on the service that should accept external/VPN traffic. See § Services.
App config keys
Dev-supplied secrets (API tokens, OAuth credentials, third-party keys) flow through app_config_keys:
app_config_keys:
- STRIPE_API_KEY
- SENTRY_DSN
- DATADOG_API_KEY
- UPPER_SNAKE_CASE, must start with a letter.
- These get
REPLACE_MEplaceholders in{app}/configSecrets Manager on first apply. Devs populate values via GitHub env settings → ESO sync (NOT by editing AWS Secrets Manager directly). - PR sync semantics:
app_config_keyschanges on a PR (additions / removals) are reflected in the PR's preview environment before merge. On merge tomain, the same diff flows through to stg/prod placeholders. - Reserved prefixes (rejected at scaffold time by forge-render, before TF rendering): see Reserved env-var name prefixes below for the full list. These flow through the platform-managed ConfigMap, not through dev-supplied secrets. Putting them in
app_config_keysis a mistake.
Env vars
Static per-environment config variables injected directly into the app's platform ConfigMap. Use for non-secret values that differ per environment (e.g., the app's own public URL). Secrets should go through app_config_keys + GitHub Environment Secrets instead.
env_vars:
EXTERNAL_HOSTNAME:
prev: "https://auth.prev.conservice.ai"
stg: "https://auth.stg.conservice.ai"
prod: "https://auth.conservice.ai"
Each key is an UPPER_SNAKE_CASE env var name; the value is a map of prev / stg / prod to the string value for that environment. All three envs must be present for each key.
- Delivery path: forge-render emits the values into the app's
{app}-envConfigMap at kustomize-render time. The pod picks them up viaenvFrom. - Reserved prefixes rejected: The same prefixes reserved for platform-managed vars (
DATABASE_*,S3_BUCKET_*,AWS_REGION, etc.) are rejected at schema validation. See Reserved env-var name prefixes. - Typed env contract: Keys declared in
env_varsappear in the generatedsrc/env.d.tsandsrc/lib/env.tsfiles, so TypeScript apps get compile-time type checking. - Preview: The
prevvalue is used for all preview environments (per-PR envs inherit thepreventry).
Resources
Optional top-level block declaring AWS resources the app needs. Every resource type is optional. Unknown top-level keys (e.g., a typo'd resources.bedrocks) are rejected.
Naming convention: account-scoped resources (SQS, SNS, EventBridge, Step Functions, DynamoDB, Firehose, KMS, IAM) use
{env}-{region}-{app}-{key}per the platform naming convention — no prefix, since the account ID in every ARN already disambiguates. S3 buckets are the one exception: they retain theconservice-prefix because S3 bucket names are globally namespaced and need a company prefix to prevent collisions across all of AWS.
resources:
s3:
chat-history:
versioning: true
sqs:
jobs: {}
notifications:
dlq: true
dlq_retention_seconds: 1209600
database:
main:
extensions: [vector, uuid-ossp]
bedrock:
model_ids:
- us.anthropic.claude-sonnet-4-20250514-v1:0
- amazon.titan-embed-text-v2:0
kms:
token-envelope:
description: "Envelope-encrypt OAuth tokens before writing to DDB"
actions: [Encrypt, Decrypt, GenerateDataKey]
Resource key naming (applies to ALL resource types)
Map keys must be:
- 1-64 chars (resource-specific tighter caps noted per type below)
- Lowercase, start with a letter
- Contain only
[a-z0-9_-] - NOT start with
pr-— reserved for per-PR ephemeral resources
The key becomes the resource suffix. Example: s3.chat-history → bucket conservice-{env}-{app}-chat-history.
Per-resource grant fields (s3 / sqs / dynamodb / eventbridge / database)
These resource types may carry optional grant arrays. They name principals that should get tiered access to this resource only — narrower than a full app-level grant. (sns, stepfunctions, firehoses, and kms don't currently take the array-shaped grants; they participate only in the cross-app access: policy described below. KMS has a parallel-but-nested access.team_grants shape — see § KMS.)
team_grants: [{ team, tier }]— give a team's per-team Permission Set tiered access to this resource.teamis the kebab slug (resolves toteam-{team}@conservice.com);tierisadminorreadonly.user_grants: [{ email, tier }]— direct-add a single@conservice.comuser (use sparingly;team_grantsis preferred for code-review auditability).group_grants: [{ group, tier }]— give a non-team Google group (e.g.conservice-finance@conservice.com) tiered access.
Status of materialization (forge-schema 2.x):
database.{name}.team_grants/user_grants— fully wired end-to-end. The renderer materializes the per-app PG login role and the cross-teamrds-db:connectARN on the team's Permission Set. Recipient canpsqlinto the DB and has NO access to the app's other AWS resources.database.{name}.group_grants— accepted at the schema layer; materializes via theteam_dbsenumeration at the per-team Permission Set.s3/sqs/dynamodb/eventbridgeteam_grants/user_grants/group_grants— accepted at the schema layer; renderer-side consumption ships in a follow-on phase. Declaring entries today has no runtime effect on these resource types yet, but the doc shape is stable.
Per-resource cross-app policy (access / allowed_teams / allowed_apps / tags)
The eight resource kinds that participate in cross-app consumes: (s3, sqs, sns, dynamodb, eventbridge, stepfunctions, firehoses, database) each accept an optional cross-app consume policy:
resources:
s3:
embeddings-cache:
access: team
allowed_teams: [ai]
tags:
sensitivity: pii
| Field | Type | Notes |
|---|---|---|
access | enum | open (any app in the org may declare consumes: against this resource), team (only apps owned by a team in allowed_teams), app (only apps in allowed_apps — most restrictive). Auto-defaults to team for database and for any resource carrying tags.sensitivity ∈ {pii, pci, hipaa, soc2} — declare explicitly to override. |
allowed_teams | array of team slugs | Required non-empty when access: team is set explicitly. Empty array ([]) is a parse error — either omit, add an entry, or pick a different access level. |
allowed_apps | array of app names | Required non-empty when access: app is set explicitly. Same kebab-case rules as app_name. |
tags.sensitivity | enum | pii, pci, hipaa, soc2 (auto-default access: team), or public / internal (positive-intent labels, no auto-default). Closed enum — typos like confidential fail at parse time. |
Consumer-side declarations go in the top-level consumes: block.
S3 (resources.s3)
s3:
history:
versioning: true
uploads: {}
| Field | Type | Notes |
|---|---|---|
versioning | bool | Enable S3 versioning. Default: true. All platform buckets default versioning ON; set false only when you don't want object history. |
KMS encryption + public-access-block are always on. Per-bucket team_grants / user_grants / group_grants are accepted (renderer-side consumption is in flight — see above).
Bucket-key cap: 20 chars (enforced by the Terraform module, not Zod). S3's 63-char bucket-name limit minus conservice-{env}-{app}- prefix leaves ~24 chars; cap at 20 for safety.
Resolved bucket name: conservice-{env}-{app}-{key} (e.g., conservice-prod-my-app-history). S3 retains the conservice- prefix because S3 bucket names are globally namespaced.
SQS (resources.sqs)
sqs:
jobs:
visibility_timeout: 30
retention_seconds: 1209600
dlq: true
max_receive_count: 5
| Field | Type | Notes |
|---|---|---|
dlq | bool | Provision a Dead-Letter Queue and wire the redrive policy on the main queue. Default: true. |
dlq_retention_seconds | int | DLQ retention in seconds. Default: 1209600 (14 days, AWS max). |
visibility_timeout | int | 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 | int | Main-queue retention in seconds. Default: 345600 (4 days; AWS max 14 days). |
max_receive_count | int | DLQ-trigger threshold. Default: 5. Lower for fail-fast; higher when transient retries are normal. |
Server-side encryption via SQS-managed keys is always on. The pod role gets Send/Receive/Delete on the queue and its DLQ.
Resolved name: {env}-use1-{app}-{key}-queue.
SNS (resources.sns)
sns:
events: {}
No type-specific knobs declared today — empty object opts in. The pod role gets sns:Publish on the topic ARN.
Resolved name: {env}-use1-{app}-{key}-topic.
DynamoDB (resources.dynamodb)
dynamodb:
sessions:
hash_key: id
hash_key_type: S
ttl_attribute: expires_at
point_in_time_recovery: true
events:
hash_key: stream_id
range_key: event_id
range_key_type: S
billing_mode: PAY_PER_REQUEST
gsi:
by-status:
hash_key: status
range_key: created_at
projection_type: ALL
| Field | Type | Notes |
|---|---|---|
hash_key | string | Required. Partition key attribute name. |
hash_key_type | enum | S (string), N (number), B (binary). Default: S. |
range_key | string | Sort key attribute name. |
range_key_type | enum | Same set as hash_key_type. Default: S. |
billing_mode | enum | PAY_PER_REQUEST (default) or PROVISIONED. |
gsi | object map | Global Secondary Indexes. Each: hash_key, range_key?, projection_type?. |
ttl_attribute | string | Attribute holding TTL epoch seconds. Enables TTL when set. |
point_in_time_recovery | bool | Default: true. |
KMS-encrypted by default. The auto-emitted pod-role policy covers data-plane read/write/query/scan PLUS dynamodb:DescribeTable (per conservice-app-baseline v10.3.0) — DescribeTable is the canonical no-op control-plane probe for readiness checks (verifies IAM + resource exists without leaking item data), so app /readyz handlers can call it without hitting AccessDenied.
Resolved name: {env}-use1-{app}-{key}.
EventBridge (resources.eventbridge)
eventbridge:
domain:
rules:
order-placed:
pattern:
source: ["my-app.orders"]
detail-type: ["OrderPlaced"]
description: "Fire on new orders"
| Field | Type | Notes |
|---|---|---|
rules | object map | Each rule: pattern (object — EventBridge event pattern, validated server-side at apply time), description (string). |
Resolved bus name: {env}-use1-{app}-{key}.
Step Functions (resources.stepfunctions)
stepfunctions:
flow:
type: STANDARD
definition: |
{
"StartAt": "Hello",
"States": { "Hello": { "Type": "Pass", "End": true } }
}
log_level: ALL
log_retention_days: 30
| Field | Type | Notes |
|---|---|---|
type | enum | STANDARD (default) or EXPRESS. |
definition | string | Required. ASL JSON definition. |
log_level | enum | ALL / ERROR / FATAL / OFF. |
log_retention_days | int | CloudWatch log retention. |
Key cap: 16 chars (enforced by the Terraform module — IAM role name {prefix}-sfn-{key}-role hits AWS's 64-char ceiling; not enforced in Zod).
Resolved name: {env}-use1-{app}-{key}.
Bedrock (resources.bedrock)
bedrock:
model_ids:
- us.anthropic.claude-sonnet-4-20250514-v1:0
- amazon.titan-embed-text-v2:0
| Field | Type | Notes |
|---|---|---|
model_ids | array of strings | Required. Non-empty. AWS Bedrock model IDs. Adds bedrock:InvokeModel to the pod role. |
knowledge_bases | bool | Adds Knowledge Base API permissions. Default: false. |
guardrails | bool | Adds bedrock:ApplyGuardrail permission. Default: false. The per-app guardrail resource itself is not provisioned yet — IAM scope only. |
Model ID validation: Each entry must be a valid Bedrock invocation target — either a versioned foundation-model ID ending in :N (e.g. amazon.titan-embed-text-v2:0) or a cross-region inference profile starting with a region prefix (us. / eu. / apac. / global. / ap.). Anthropic models REQUIRE a regional inference profile prefix — bare anthropic.* IDs are rejected at parse time because AWS Bedrock fails them at invocation with "on-demand throughput isn't supported" (validated 2026-05-09).
Common gotcha: the schema accepts model_ids (plural, underscore). The block uses strict-key validation, so any unknown key fails — including the common typos enabled, models, and singular model_id. Presence of the block (non-null) is the opt-in; there's no enabled: true.
Database (resources.database)
database:
main:
extensions: [vector, uuid-ossp]
connection_limit: 100
team_grants:
- team: data
tier: readonly
user_grants:
- user: alice
tier: admin
migrations:
command: ["npm", "run", "migrate"]
runs_on: [prev, stg, prod]
| Field | Type | Notes |
|---|---|---|
extensions | array of strings | PostgreSQL extensions to enable. Allowlist: vector (NOT pgvector — the schema rejects pgvector; pgvector is the project name, vector is the extension name), uuid-ossp, pg_trgm, hstore, citext, postgis, btree_gist, btree_gin, unaccent, fuzzystrmatch. |
connection_limit | int | Per-role PostgreSQL CONNECTION LIMIT. Default: unlimited at the module. |
team_grants | array | Per-DB team grants — { team: <slug>, tier: admin|readonly }. On apply, the team's per-team Permission Set gains rds-db:connect on arn:aws:rds-db:*:*:dbuser:*/aws-{app}-db-{tier}. Recipient gets DB-ONLY AWS access (no S3, queues, secrets-other-than-the-DB-secret, console for any other app resource). |
user_grants | array | Per-DB user grants — { user: <google-username>, tier: admin|readonly }. Narrow per-user exception adding rds-db:connect on aws-{app}-db-{tier} for a single individual. user is the LEFT side of @conservice.com (e.g. alice, bob.smith) — no @conservice.com suffix. |
group_grants | array | Per-DB Google-group grants — { group: <conservice.com group email>, tier }. For non-team groups (e.g. conservice-finance@conservice.com). Materializes via the team_dbs enumeration at the per-team Permission Set. |
migrations | object | Schema-bootstrap migration runner. { command: [string], runs_on?: [env] }. When set, the renderer emits a migration-job.yaml ArgoCD Sync hook per env overlay (gated by runs_on, default [prev, stg, prod]). Sync-wave ordering ensures app Deployments only roll after the Job completes. command is an argv array — each entry is a literal string with no shell expansion. |
Database tenancy: Aurora is a SHARED cluster across all apps in an env. Each database.{key} declaration creates a logical PostgreSQL database inside the shared cluster.
Resolved DB name: {app_underscored} (hyphens become underscores). Service user: {app_underscored}_svc.
engine is NOT a valid key. Always aurora-postgresql (set at the platform layer). Strict-key validation rejects any unknown key — engine is a particularly common one to accidentally include because it's standard in raw RDS Terraform. Don't.
Who has DB access
Two grant surfaces apply, layering from broad → narrow. Both wire to the same per-app PostgreSQL login role aws-{app}-db-{tier} (the -db- infix marks the role as DB-scoped).
1. Team-keyed full AWS access (configured outside forge.yaml) — the owning team and any team listed in team_dbs get full AWS access via per-team Permission Sets (team-{team}-{env_short}-admin / team-{team}-{env_short}-readonly). AWS access is keyed by team, not by app — there is no aws-{app}-admin/readonly group anymore. Engineering members of the owning team's team-{team}@conservice.com group automatically pick up aws-team-{team}-{tier} membership at scaffold time.
2. Per-DB grants on database.{name} — narrow scope, DB-only. These are the declarations developers write in forge.yaml:
- Teams listed in
database.{name}.team_grantswithtier: adminget full read/write on every database the app owns, via PostgreSQL login roleaws-{app}-db-admin(cluster-scoped, inherits the per-app tier role{app}_admin). No access to S3, queues, secrets (other than the DB connection secret), or AWS console for any other app resource. - Teams listed with
tier: readonlyget SELECT-only via login roleaws-{app}-db-readonly. Same DB-only narrow scope.
Per-DB grants are the right surface for cross-team data access (e.g. team-data needs read-only access to the billing app's databases but should NOT see S3, queues, or other resources). Per-app, not per-DB, at the PG-role layer: multiple teams requesting the same tier share one cluster-level login role (anti-quadratic); per-team identity gating happens at the per-team Permission Set layer, which targets the role ARN. Multi-DB apps with truly distinct grant needs per database are deferred to a future ADR.
The PG roles + IAM auth (rds_iam) are provisioned automatically by the platform. Apps don't declare DB users directly in forge.yaml — the team_grants / user_grants arrays above are the entire dev-facing surface.
Removed fields (forge-schema 2.0.0):
database.{name}.admin_groups,readonly_groups,admin_users,readonly_usersare no longer accepted by the schema. They were orphaned plumbing — created PG roles inside Aurora but no SSO Permission Set ever grantedrds-db:connectagainst the role names they produced. Useteam_grants/user_grantsinstead.
Temporal (resources.temporal)
temporal:
retention_days: 30
api_key_expiry: "2027-05-03T00:00:00Z"
| Field | Type | Notes |
|---|---|---|
retention_days | int | Workflow history retention. Default: 30. |
api_key_expiry | string | Required. ISO 8601 timestamp. Pinned at scaffold; runtime re-reads from forge.yaml (deterministic re-render: byte-identical input produces byte-identical output). Rotate by editing this value and re-rendering. |
Common gotchas: rejected keys include enabled, namespace, regions, search_attributes, enable_delete_protection. Presence of the block is the opt-in. Namespace is derived from app_name.
Resolved namespace: {app}-{env}.<your-temporal-cloud-namespace>.
Firehose (resources.firehoses)
firehoses:
webhook-archive:
destination: s3
bucket: webhook-events # MUST match a key in resources.s3
prefix: "events/"
buffer_size_mb: 5
buffer_interval_seconds: 300
compression: GZIP
| Field | Type | Notes |
|---|---|---|
destination | string | Required, only s3 today. Redshift / OpenSearch / Splunk are deferred. |
bucket | string | Required. Must match a key declared in resources.s3 (cross-validated at parse time). |
prefix | string | S3 key prefix for delivered records. Default: "". |
buffer_size_mb | int | 1-128. Default: 5. |
buffer_interval_seconds | int | 60-900. Default: 300. |
compression | enum | UNCOMPRESSED, GZIP (default), SNAPPY, ZIP, HADOOP_SNAPPY. |
Key cap: 16 chars (enforced by the Terraform module — IAM role {prefix}-fh-{key}-role hits 64-char ceiling; not enforced in Zod).
Resolved name: {env}-use1-{app}-{key}.
KMS (resources.kms)
Per-app customer-managed KMS keys (CMKs) for app-initiated envelope encryption — e.g., an auth service that envelope-encrypts upstream IdP tokens before writing them to DynamoDB. Distinct from the AWS-managed SSE-KMS that already covers DDB and S3 at rest (alias/aws/dynamodb / alias/aws/s3); that's transparent to the app. The case here is app code calling kms:Encrypt / Decrypt / GenerateDataKey directly against a CMK the app controls.
resources:
kms:
token-envelope:
description: "Envelope-encrypt upstream IdP tokens before DDB writes"
actions:
- Encrypt
- Decrypt
- GenerateDataKey
- DescribeKey
rotation: enabled
Key name (the map key) is kebab-case, 2-20 chars, must start with a letter and end alphanumeric. Same reserved-prefix list as app_name. Pick a name that describes the encryption purpose (token-envelope, secrets), not the resource type.
| Field | Type | Notes |
|---|---|---|
description | string | Optional human-readable description (shown in the KMS console). When rotation: disabled is set, the description should mention the compliance reason — auditor-friendly. Max 8192 chars. |
key_spec | enum | SYMMETRIC_DEFAULT (default; only value accepted in v1). Asymmetric specs (RSA_*, ECC_*) are deferred — they need a different action allowlist (Sign/Verify/GetPublicKey) and will arrive with a future ADR. |
actions | array of strings | Required, non-empty. Data-plane KMS actions to grant to the pod role on this key. Allowlist: Encrypt, Decrypt, GenerateDataKey, GenerateDataKeyWithoutPlaintext, ReEncryptFrom, ReEncryptTo, DescribeKey. Unknown verbs fail at parse time. Key administration verbs (CreateKey, ScheduleKeyDeletion, PutKeyPolicy, ...) are deliberately omitted — key lifecycle stays in Terraform. |
rotation | enum | enabled (default — annual KMS rotation) or disabled. Set disabled only with a compliance reason in description. |
tags | object (string→string) | Optional pass-through tags applied to the KMS key. Standard k/v string map; no platform-side validation beyond the type. Use for cost-allocation (cost_center: ...) or compliance markers. |
access.team_grants | array | Per-key team grants (accepted at the schema level; module/renderer consumption deferred to a follow-on ADR — declaring entries today has no runtime effect). |
Resolved name: {env}-{region_code}-{app}-{key_name}-key (e.g., prod-use1-auth-service-token-envelope-key).
Resolved alias: alias/{env}-{region_code}-{app}-{key_name}-key.
Auto-emitted env vars (KMS_KEY_ is on the reserved env-var prefix list):
KMS_KEY_{KEY_NAME_UPPER_SNAKE}_ID(e.g.KMS_KEY_TOKEN_ENVELOPE_ID)KMS_KEY_{KEY_NAME_UPPER_SNAKE}_ARN
App code reads these from the pod environment — never construct a KMS key ID or alias in app code.
Cross-app access (per-resource policy + consumes)
Cross-app resource access is a two-sided contract: producers declare who is allowed to consume each resource (the per-resource access / allowed_teams / allowed_apps / tags fields documented above); consumers declare which producer resources and services they want to use, via the top-level consumes: block.
# Consumer side: my-app declares it wants to read rates' embeddings cache
consumes:
resources:
- producer_app: rates
resource_kind: s3
resource_key: embeddings-cache
actions: [read]
services:
- producer_app: rates
service_name: api
| Field | Type | Notes |
|---|---|---|
consumes.resources | array | Cross-app resource declarations. Each entry: { producer_app, resource_kind, resource_key, actions: [string] }. The resolver matches each entry against the producer's resources.{kind}.{key} and the producer's access / allowed_teams / allowed_apps policy at scaffold/modify time. |
consumes.services | array | Cross-app service declarations. Each entry: { producer_app, service_name }. Data-only in v1 — no Istio AuthorizationPolicy is emitted yet (deferred to a future ADR). Records intent so future wiring lands without a schema migration. |
producer_app follows the same kebab-case rules as app_name (reserved prefixes rejected). resource_kind is one of: s3, sqs, sns, dynamodb, eventbridge, stepfunctions, firehoses, database.
actions: is high-level, not raw IAM verbs — entries like read, write, consume, produce, admin. The action-expansion utility maps each high-level action to per-kind IAM actions at emit time; unknown high-level actions for a kind throw at scaffold/modify time (NOT at schema parse — the schema treats actions as opaque strings to keep the kind/action coupling in one place).
Same-account only in v1. Cross-account consumes raise cross_account_not_supported at resolve time rather than at parse time.
Preview-environment plumbing: when the consumer is itself running in a per-PR preview environment, the resolver injects the consumer's pr_number into non-S3 ARNs to disambiguate per-PR resources (S3 is per-app, not per-PR, so its ARN doesn't carry the segment).
Auth
Integration with the platform's Auth Service — sets up Workspace tier groups, nests the owning team, and registers the app's tier→group mappings with the Auth admin API.
auth:
access_mode: restricted
tiers:
- name: admins
group: app-rates-admins@conservice.com
seed_members:
- alice@conservice.com
- name: users
group: app-rates-users@conservice.com
| Field | Type | Required | Notes |
|---|---|---|---|
access_mode | enum | no | restricted (default — only declared tier-group members may access) or all (any authenticated @conservice.com user; Auth gates authentication but not per-tier authorization). |
strict | bool | no | Default: false. When true, the Auth admin UI cannot grant additional group access beyond what's declared here — forge.yaml is the only authoritative source. Recommended for prod-critical or Aurora-touching apps. |
self_register | bool | no | Default: false. Set true ONLY for the Auth Service itself — when true, forge skips the Auth admin-API write steps because the app self-registers via startup migration. Every other app leaves this false. |
hidden | bool | no | Default: false. Hides the app's tile from the Portal dashboard. The app remains reachable at its hostname; it just doesn't appear in the user's tile grid. |
tiers | array | yes | At least one tier when auth: is set. Each tier: { name, group, seed_members }. |
Per-tier shape:
| Field | Type | Notes |
|---|---|---|
name | string | Lowercase + digits + underscores, starts with a letter (e.g. admins, users). Forge passes this to the Auth admin API and surfaces it as the X-Auth-Tier header on requests reaching the app, so the app can branch on tier. |
group | string | Lowercased @conservice.com email of the Google group backing this tier (e.g. app-rates-admins@conservice.com). Forge creates the group if missing. When tiers users and admins are both declared, the users group is nested into the admins group so admins inherit user-tier access. Custom tier names are not auto-nested. |
seed_members | array | Default: []. Direct member @conservice.com emails added to the tier group at scaffold time. Used primarily for Auth bootstrap (e.g. app-auth-admins seeded with the initial platform-admin emails); empty for most apps. |
Safe default when auth: is omitted: Forge applies closed-restricted mode with a single auto-generated admins tier on app-{app}-admins@conservice.com. The default is synthesized at create time, NOT in the schema — so on-disk forge.yaml stays a faithful echo of what the user wrote.
Environment opt-out (disabled_envs)
Apps deploy to all platform envs by default (prev, stg, prod). Use disabled_envs: to opt an app out of one or more — useful for tooling-only apps that don't need a preview env, or single-env utilities.
disabled_envs: [prev]
| Field | Type | Notes |
|---|---|---|
disabled_envs | array of enums | Each entry must be prev, stg, or prod. Omit the field entirely (or set []) to deploy to all envs. |
Per-env resource overrides (different SQS retention in prod vs stg, etc.) aren't supported in this schema today. Tune at the resource level instead.
Legacy
environments:block — deprecated. Earlier schema versions accepted a top-levelenvironments: { prev: {...}, stg: {...}, prod: {...} }block carrying per-envaccount_id. The block is still accepted for back-compat with existing forge.yaml files, but the renderer ignores it — account IDs are platform constants sourced from forge-render'sPLATFORM.ENVIRONMENTS, and the enabled-env list is derived from the platform default minusdisabled_envs:. Drop the block on your next regen.
Preview
Per-PR ephemeral environment opt-in.
preview:
enabled: true
| Field | Type | Notes |
|---|---|---|
enabled | bool | Default: false. |
When true, every PR gets a preview env at pr-{N}-{app}.prev.conservice.ai (VPN-only). 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. Per-PR ephemeral resources (DBs, SQS queues, S3 buckets keyed pr-{N}-{name}) provision via conservice-app-pr-resources. The pod IAM role gains anchored-wildcard ARNs for pr-* resources.
Image tags
Per-env image tags managed by Kargo after each promotion. You don't set these manually — Kargo's argocd-update step writes the promoted commit SHA into forge.yaml after each successful promotion.
image_tags:
stg: bd55eef2ffe08f1c7ad67b7ac14f1b2d69e1fc9a
prod: PROMOTION_PENDING-prod
| Key | Description |
|---|---|
stg | Image tag for staging. Written by Kargo after stg promotion. |
prod | Image tag for production. Written by Kargo after prod promotion. |
Before the first Kargo promotion, the value is PROMOTION_PENDING-{env} — a sentinel that renders an obviously-unhealthy state rather than silently pulling latest.
Replicas
Per-env replica counts (source of truth for the Deployment's replicas field).
replicas:
stg: "2"
prod: 0
Values are strings (YAML scalars). 0 means the env is provisioned but not running pods (e.g., prod before the first promotion). Forge-render emits these into the kustomize overlay's Deployment patch.
Canary
Boolean flag opting the app into the advance-on-every-publish forge-render pin channel. Default: false.
canary: true
When true, the app's CI workflows use FORGE_RENDER_VERSION_CANARY instead of FORGE_RENDER_VERSION_GENERAL. Canary apps get new forge-render features immediately on publish; general apps wait for the pin to be manually advanced. Useful for testing template changes before fleet-wide rollout.
Reserved naming
Reserved key prefixes (resource map keys)
pr-— reserved for per-PR ephemeral resources (conservice-app-pr-resources). A static bucket keyedpr-foowould produce{prefix}-pr-foo, colliding with the per-PR wildcard{prefix}-pr-*-*. Schema rejects allpr-*keys ins3,sqs,dynamodb, etc.
Reserved env-var name prefixes
These are platform-managed; do NOT include them in app_config_keys or shadow them in services[].env. They're injected automatically:
DATABASE_*— Aurora connection metadataS3_BUCKET_*— bucket namesSQS_QUEUE_*— queue URLsSNS_TOPIC_*— topic ARNsEVENTBRIDGE_BUS_*— bus namesDYNAMODB_TABLE_*— table namesSFN_ARN_*— state machine ARNsFIREHOSE_STREAM_*— firehose stream namesBEDROCK_*— Bedrock configKMS_KEY_*— per-app CMK ID + ARN env vars
And the exact-match reserved names (whole name reserved, not a prefix):
AWS_REGIONTEMPORAL_ADDRESSTEMPORAL_NAMESPACE
Reserved app-name prefixes (forge schema rejection list)
Forge rejects app names starting with these:
csvc-— legacy naming prefix; retained in the rejection list to prevent resurrectionconservice-— reserved for global-namespace S3 buckets onlyaws-— group prefixapp-— group prefix + repo prefix for dev-owned reposinfra-— repo prefix for SRE-owned reposforge-— forge platform itselfk8s-— Kubernetes-managed
The same list is reused to validate resources.kms.<key_name> — pick a logical name that describes the encryption purpose, not a platform prefix.
Naming patterns (resolved at scaffold)
| Resource | Pattern | Example |
|---|---|---|
| Repo | conservice-ai/{app} | conservice-ai/my-app |
| Namespace | {app} | my-app |
| ECR repo | apps/{app}-{service} | apps/my-app-api |
| Aurora DB name | {app_underscored} | my_app |
| Aurora service user | {app_underscored}_svc | my_app_svc |
| S3 bucket | conservice-{env}-{app}-{key} | conservice-prod-my-app-history |
| SQS queue | {env}-use1-{app}-{key}-queue | prod-use1-my-app-jobs-queue |
| SNS topic | {env}-use1-{app}-{key}-topic | prod-use1-my-app-events-topic |
| EventBridge bus | {env}-use1-{app}-{key} | prod-use1-my-app-domain |
| Step Function | {env}-use1-{app}-{key} | prod-use1-my-app-flow |
| DynamoDB table | {env}-use1-{app}-{key} | prod-use1-my-app-sessions |
| Firehose stream | {env}-use1-{app}-{key} | prod-use1-my-app-events |
| KMS key | {env}-use1-{app}-{key}-key | prod-use1-auth-service-token-envelope-key |
| KMS alias | alias/{env}-use1-{app}-{key}-key | alias/prod-use1-auth-service-token-envelope-key |
| Pod IAM role | {env}-use1-{app}-pod-role | prod-use1-my-app-pod-role |
| Secrets path | {app}/{key} | my-app/api-token |
| Temporal namespace | {app}-{env}.<temporal-cloud-namespace> | my-app-prod.<temporal-cloud-namespace> |
| Per-team Permission Set (Identity Center) | team-{team}-{env_short}-{tier} | team-ai-prod-admin — kebab-case. Drives team AWS Console access AND per-app DB IAM access (via rds-db:connect ARNs in the inline policy, enumerated from the SRE-managed team_apps / team_dbs tfvars). |
| PG login role (per-app, cluster-scoped) | aws-{app}-db-{tier} | aws-rates-db-admin — created by conservice-app-database v1.22.0+ when database.{name}.team_grants is declared. One role per (app, tier) at the Aurora cluster level; multiple teams sharing a tier share one login role. The -db- infix marks it as DB-scoped (the role gates ONLY PostgreSQL access — every other AWS service uses per-team Permission Sets). |
| PG tier role (per-app, NOLOGIN, holds object grants) | {app}_admin / {app}_readonly | rates_admin, rates_readonly — table/sequence + DEFAULT PRIVILEGES on each DB the app owns. The login role above inherits from these. |
| App tier groups (Workspace, used by Auth Service) | app-{app}-{tier} | app-rates-admins — used by auth.tiers[].group for in-app role gating. The Auth Service authorizes the request and forwards the X-Auth-Tier header to the app (the legacy ALB-OIDC gate has been retired). |
| Kargo project | {app} | my-app (literal — no env suffix) |
App env vars are uniform: {TYPE}_{KEY} matches the AWS name's {key}. A bucket keyed history becomes env var S3_BUCKET_HISTORY containing conservice-prod-my-app-history. A KMS key named token-envelope becomes env vars KMS_KEY_TOKEN_ENVELOPE_ID and KMS_KEY_TOKEN_ENVELOPE_ARN. Always read names from env vars; never construct them in app code.
Schema versioning
Two distinct version lines apply to forge.yaml:
forge_versionis the YAML schema-contract value users put in their file. Today:1.0.0. It declares which schema contract the file expects. When a breaking change ships (e.g.2.0), forge-render carries both renderers in parallel for a deprecation window; apps see a deprecation warning onforge_statusuntil they migrate, and after the window 1.x apps fail at scaffold/modify (existing infra keeps running).- The URL major version (
v1) is the schema's published-contract major. It is independent of any internal Conservice Terraform module versions — the URL versions the public schema contract, not the implementation. Theforge-schemanpm package version (independent of either) is the version of the Zod validator that produced the JSON Schema artifact on this site.
Versioning policy for this doc
- Each major schema version (
v1,v2,v3, ...) gets its own URL path:schemas.conservice.ai/forge/v1/,schemas.conservice.ai/forge/v2/,schemas.conservice.ai/forge/v3/. - Old majors stay live forever. An app pinned to
v1keeps validating against the v1 schema even after v2 ships. - Within a major (v1.0 → v1.x), this doc updates in place. Additive changes (new fields, relaxed constraints) don't bump the major.
- Breaking changes (renamed fields, removed fields, tightened constraints) bump the major.
Where to file requests
- Need a new resource type (DocumentDB, OpenSearch, etc.) — file a platform request to extend
conservice-app-resources. Don't add rawresourceblocks to your repo — the IaC guardrail rejects PRs. - Need an existing knob exposed (S3 lifecycle, DynamoDB autoscaling, etc.) — same. Platform team adds it to the module + this schema doc.
- Need to tune a per-env value that's currently uniform — file a request; per-env override support is a roadmap item.
- Found a bug in this doc — let the Conservice platform team know.
Cross-references
- JSON Schema for IDE auto-validation:
forge.yaml.schema.json— the public, machine-readable contract. Auto-published from the internal Zod schema source on every package change. - Internal Zod schema source: generated from an internal Zod runtime validator — Conservice SREs can point you at the source if you need to read it directly. The public JSON Schema linked above is the same shape.
- Internal Terraform module: the underlying
conservice-app-resources+conservice-app-baseline+conservice-app-databasemodules are internal — this doc is the dev-facing extract. - Design rationale: the platform decision records behind the behaviors above are Conservice-internal — ask the platform team if you need the background.