The app manifest
The full app.manifest.yaml reference — every spec section, the strict schema, and the cross-field rules.
One file, one contract
Every platform-native app ships a single app.manifest.yaml at {service}/backend/app.manifest.yaml. It is the declarative contract between your service and the Control Plane: authorization resources, plan features, events, audit vocabulary, GDPR endpoints, tenancy, and operator settings are all declared here, and publishing the file wires them into the platform.
The top level is Backstage-style — apiVersion, kind, metadata, spec — and the canonical contract is a strict JSON Schema (Draft 2020-12) shipped inside the SDK. additionalProperties: false applies at nearly every level, so an unknown or misspelled key is a hard error, not a silent no-op. A few objects deliberately stay open — spec.compute and config fields among them — where the richer metadata is validated by the Control Plane itself rather than the schema. The schema is versioned independently of apiVersion: its $id is https://synaptagrid.io/schemas/app-manifest/1.34.json (title Synaptagrid App Manifest (1.34)), while apiVersion stays platform.synaptagrid.io/v1.
Validation happens in the same code twice: the egav-app-sdk CLI validates locally against the shipped schema, and the Control Plane re-runs that exact SDK validation on publish before it fans your sections out to AuthZ, notifications, the scheduler, and the event bus. There is one source of truth for what a valid manifest is.
your-service/
backend/
app.manifest.yaml <-- the contract
app/
...
# Canonical schema (the contract this file is validated against):
# egav-app-sdk/egav_app_sdk/manifest/schema/app-manifest.schema.json
# $id: https://synaptagrid.io/schemas/app-manifest/1.34.json
# Draft: JSON Schema 2020-12, additionalProperties:false (a few objects deliberately open)The envelope: apiVersion, kind, metadata, spec
Four keys are required at the root: apiVersion, kind, metadata, spec.
apiVersionis the constantplatform.synaptagrid.io/v1.kindis the constantApplication.metadatarequiresname,display_name,version, andpublisher.nameis the machine code — unique across the platform, matching^[a-z][a-z0-9-]*$.versionis semver.publishermatches^[a-z][a-z0-9-]*$and references the CP publishers table. Everything else inmetadata(tagline, categories, tags, release_status, icon/logo URLs, and more) is optional catalogue detail. Pricing is not set in the manifest: an operator assembles plans and prices in the Control Plane (see plans).specholds the functional declarations. Its only required field isfeatures— every other section is optional and adds a capability.
Here is a minimal, schema-valid envelope (abridged from a real service manifest):
apiVersion: platform.synaptagrid.io/v1 # const
kind: Application # const
metadata:
name: notifications # ^[a-z][a-z0-9-]*$ — the machine code
display_name: Notifications
version: 1.0.4 # semver
owner: platform-team
publisher: getek # ^[a-z][a-z0-9-]*$
release_status: ga
spec:
features: # the ONLY required spec section
- code: notifications.channels.email.enabled
name: Email channel
description: Enable email notifications for the org.
ui_hint: Toggle ON to allow email sends.
type: boolean
default: truespec.features — plan features and quotas
spec.features is the one required spec section. It defines the entitlements your app exposes; it does not set their per-plan values — those are bound in the Control Plane when an operator assembles a plan. Each feature declares code, name, description, ui_hint, and type (boolean, integer, or string), plus an optional unit, default, marketing labels, and show_on_pricing (see plans).
Unlocks: feature gates. Once published, a feature code can gate a route or a call at runtime (a disabled feature returns 403, an exceeded quota returns 429).
Only type: integer features may carry a quota block, which tells CP how to aggregate reported usage: source is cumulative, daily, or per_request. An optional policy (allow, notify, block, overage, suspend; default notify) sets the platform-default behaviour at the limit — overridable per plan and per org.
spec:
features:
# Boolean feature — a simple on/off entitlement
- code: notifications.channels.slack.enabled
name: Slack channel
description: Enable Slack webhook delivery.
ui_hint: Toggle ON to allow Slack sends.
type: boolean
default: false
# Integer feature with a metered quota (only integer features may carry `quota`)
- code: notifications.send_per_day
name: Daily send quota
description: Maximum notifications (all channels combined) sent per day per org.
ui_hint: Cap on daily notification sends.
type: integer
unit: sends
default: 1000
quota:
source: daily # cumulative | daily | per_requestAuthorization: resource_types, permissions, and roles
Authorization is not one authz: block — it spreads across several top-level spec keys so each trust tier stays separate:
spec.cp_permissions/spec.cp_roles— operator-tier codes that gate/cp/*routes.spec.portal_permissions/spec.portal_roles— tenant-tier codes that gate/portal/*routes.spec.resource_types— ReBAC resource types. Each carries its ownactions[]and instance-scopedinstance_roles[](for example, the owner of a comment can update and delete it). Optionalspec.role_derivationsandspec.permission_catalogadd relation-based grants and display metadata.
Unlocks: on publish, CP writes these into a new version of the AuthZ catalog — resource types, actions, permissions and roles — which every organization reads, so RequireAuthz(resource, action) checks resolve immediately.
Cross-field validation enforces scope: a role's permissions[] must resolve within its own list (cp_roles against cp_permissions, portal_roles against portal_permissions). Glob entries like *.read are allowed, but a glob that matches nothing is an error.
spec:
resource_types:
- code: support.comment
name: Support Comment
description: A reply on a support ticket. Owner = author_guid.
actions:
- read
- create
- update
- delete
instance_roles:
- code: owner
name: Owner (author)
permissions:
- support.comment.update
- support.comment.delete
# paired with spec.cp_permissions / spec.cp_roles (operator tier)
# and spec.portal_permissions / spec.portal_roles (tenant tier)spec.events and spec.kafka — the event contract
Your app's event contract lives under spec.events (with emits[] and consumes[]) plus spec.kafka for topic ownership. Every platform event is serialized as a CloudEvents v1.0 envelope on the wire; the manifest is where you declare which events exist.
Each emit requires id, topic, transport (outbox or platform_events, no default), schema_ref, version, and audience. audience is the ADR-009 routing scope and has no fallback — you must pick tenant-private, org-broadcast, or platform-broadcast so the dispatcher knows how to fan out. webhook_subscribable: true exposes the event to customer webhook subscriptions, and emit_policy: subscribed makes it listener-gated — the event is produced only when a listener or webhook is registered, so integration events stay dormant until an integrator subscribes.
Each consume requires id, topic, and schema_ref, and typically names the producer plus either a handler_consumer_group (in-process Kafka consumer) or a handler_endpoint under /v1/<service>/internal/* (HTTP-dispatched). The /events/ segment some services use is a naming convention, not a schema rule — the schema only requires the /v1/{service}/internal/ prefix.
Unlocks: topic provisioning, webhook subscribability, and delivery routing. Validation enforces that every emitted topic matches one of spec.kafka.owned_topic_patterns, and CP rejects manifests whose owned patterns overlap another app's.
spec:
kafka:
owned_topic_patterns:
- authz.role.*
- authz.role_assignment.*
consumer_group_pattern: authz-{instance_guid}
dead_letter_topic: authz.dlq
events:
emits:
- id: authz.role.created
topic: authz.role.created # must match an owned_topic_patterns entry
transport: outbox # REQUIRED: outbox | platform_events
description: A role was created.
schema_ref: manifest-schemas/events/authz-role-created.v1.json
version: 1
audience: tenant-private # REQUIRED — no fallback (ADR-009)
webhook_subscribable: true
emit_policy: subscribed # listener-gatedspec:
events:
consumes:
- id: comments.mention.created
topic: comments.mention.created
schema_ref: manifest-schemas/events/comments-mention.v1.json
version: 1
producer: comments # must resolve to comments' emits[].id
handler_consumer_group: notifications-consumer-groupspec.audit — the audit catalogue
spec.audit.event_types[] is the declarative catalogue of every audit action your app emits, so a SOC-2 reviewer can see the full vocabulary at publish time. Each entry requires a dotted code (matching ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$) and a description, plus optional actor_type (user, system, or either) and metadata_keys[] documenting the keys the event may carry.
Unlocks: a published, reviewable audit vocabulary. The code here is the exact action your service passes to the audit client, so the catalogue and the emit sites stay aligned.
spec:
audit:
event_types:
- code: automation.task.assigned
description: A user / role / group was added as a subject of a HITL task at task creation.
actor_type: system
metadata_keys:
- task_guid
- subject_user_guid
- relation_code
- code: automation.task.claimed
description: A user took the active claim on a HITL task.
actor_type: user
metadata_keys:
- task_guid
- lease_secondsspec.gdpr and spec.compliance — privacy touch points
spec.gdpr declares the internal endpoints the Control Plane calls during its cascade workflow: export, erasure, and consent. Every GDPR endpoint must sit on the internal auth tier and match ^/v1/{app_code}/internal/. erasure requires both an endpoint and a mode (cascade, anonymize, or hard_delete); set async: true with a status_endpoint for long-running deletes.
spec.compliance classifies your data — data_classification, retention_days, and the personal-data fields (a legacy pii_fields list, or the typed Fides dataset).
Unlocks: participation in unified data export and right-to-erasure. This is enforced: if spec.compliance classifies personal data, validation fails unless spec.gdpr.erasure is declared, and any Fides category must be a known one.
spec:
compliance:
data_classification: confidential
retention_days:
user_pii: 365
audit_logs: 730
soft_deleted_records: 30
gdpr:
export:
endpoint: /v1/notifications/internal/gdpr/export # ^/v1/{app_code}/internal/
erasure:
endpoint: /v1/notifications/internal/gdpr/erase
mode: cascade # cascade | anonymize | hard_delete
async: true
status_endpoint: /v1/notifications/internal/gdpr/erase/{request_id}spec.tenancy — data layer and isolation
spec.tenancy declares how your app's data is isolated. storage is required and is one of per_instance_db, per_org_db or shared_with_filter. Where each table of your app lives, and how your code reaches it, is covered in Data placement. The optional options[] list enumerates per-component isolation upgrades (db, redis, kafka, and more) — each with a code, a provisioning method, and an optional default: true.
Unlocks: what cp-fleet provisions per (instance, region) when a customer accepts a plan — from a shared pool reference to a dedicated cluster.
spec:
tenancy:
storage: per_instance_db # required: per_instance_db | per_org_db | shared_with_filter
options:
- component: db
code: shared_db
method: shared_pool_ref
default: true
- component: db
code: dedicated_db
method: pulumi_provision_dedicated_db_cluster
- component: redis
code: dedicated_redis
method: pulumi_provision_dedicated_redisspec.config vs spec.required_env
Two different mechanisms, often confused:
spec.configdeclares operator-tunable settings as tabs → sections → groups → fields. This drives the Control Plane Settings UI, and it replaced the old standalone<service>.manifest.yaml. New operator settings — feature flags, TTLs, retry counts, rotation policies — belong here, not in raw environment variables.spec.required_envdeclares the environment variables the Control Plane injects at provision time (each mapped to asourcesuch ascp,db,redis,kafka,secret, orstatic). Missing required entries fail provisioning loudly.
Unlocks: a typed, operator-facing settings surface (spec.config) and correct runtime wiring at deploy (spec.required_env).
spec:
required_env:
- { name: APP_API_KEY, source: secret, source_field: inbound_api_key, secret: true }
- { name: CONTROL_PLANE_BASE_URL, source: cp, source_field: base_url }
- { name: CONTROL_PLANE_API_KEY, source: cp, source_field: api_key, secret: true }
- { name: DATABASE_URI, source: db, source_field: uri, secret: true }
- { name: KAFKA_BOOTSTRAP_SERVERS, source: kafka, source_field: bootstrap_servers }
# operator-tunable settings go under spec.config (tabs/sections/groups/fields),
# NOT here — required_env is for provision-time wiring only.Validation is two-pass
validate_manifest() runs two passes, and the second only runs if the first is clean:
- JSON Schema (Draft 2020-12) against the shipped
app-manifest.schema.json. BecauseadditionalProperties: falseis set on nearly every object, unknown keys fail here (the few deliberately-open objects, likespec.computeand configfields, are the exception). - Cross-field checks the schema cannot express, including:
- each role's permissions[] resolves within its own scope list (glob supported; a zero-match glob is an error); - every emitted topic matches a spec.kafka.owned_topic_patterns entry (and owned_topic_patterns must be non-empty when emits[] is present); - notification template (code, channel) pairs are unique; - spec.compliance personal data must declare a spec.gdpr.erasure path, and every Fides category must be known; - an action's required_permission and gated_by_feature must reference declared codes.
The Control Plane re-runs this same validate_manifest() on publish (through its validate_manifest_yaml wrapper), so what passes locally is what CP accepts on publish.
A service also registers its manifest at startup, and that check is stricter. A service built on egav_base sends its manifest to CP's config registration when it starts. That registration refuses two manifests the local validator accepts, and the service then starts without its settings (manifest.pre_config_publish_failed in its log):
spec.config.tabsis required. Declare at least one tab. Theegav-templatescaffold shipsnetworking(CORS) andinfrastructure(database pool, rate limits) tabs; keep them when you start from it.metadata.versionmust be1.0.0or later. The config version is the major number ofmetadata.version, and it must be at least 1, so0.x.yis rejected (version: 0 is less than the minimum of 1). Start at1.0.0and bump from there.
SCHEMA_PACKAGE = "egav_app_sdk.manifest.schema"
SCHEMA_FILENAME = "app-manifest.schema.json"
def validate_manifest(data: dict[str, Any]) -> list[str]:
errors = _schema_errors(data) # pass 1: JSON Schema (Draft 2020-12)
if not errors:
errors.extend(_cross_field_errors(data)) # pass 2: cross-field rules
return errorsValidate and publish
Installing egav-app-sdk also installs its CLI. Validate locally against the shipped schema, then publish to the Control Plane:
egav-app-sdk manifest validate <path>runs both passes against the bundled schema — no network needed.egav-app-sdk manifest publish <path> --app-code <code>readsEGAV_CP_URLandEGAV_CP_API_KEYfrom the environment and POSTs the manifest (with event schemas inlined) toPOST {cp}/v1/control-plane/internal/applications/{app_code}/manifest, authenticated withx-api-key.
On publish, CP re-validates and then syncs your sections outward — AuthZ resources and roles, plan features, the audit catalogue, notification templates and layouts, scheduled tasks, and the event registry.
# 1) Validate locally (offline, against the bundled JSON Schema)
egav-app-sdk manifest validate backend/app.manifest.yaml
# 2) Publish to the Control Plane
export EGAV_CP_URL=https://cp.your-domain.example
export EGAV_CP_API_KEY=control-plane-api-key
egav-app-sdk manifest publish backend/app.manifest.yaml --app-code notifications
# -> POST {cp}/v1/control-plane/internal/applications/{app_code}/manifest (x-api-key)Every spec section at a glance
The full set of spec sections in the 1.34 schema. Only features is required; each other section adds a capability when present. Every domain below has a dedicated deep-dive page — this table is the map.
| Section(s) | Declares / unlocks | Full page |
|---|---|---|
features, products | Plan entitlements, metered quotas, one-off SKUs | Plans, features & quotas |
cp_permissions/cp_roles, portal_permissions/portal_roles, resource_types, role_derivations, permission_catalog | RBAC + ReBAC authorization, synced into AuthZ | Identity & access |
events, kafka | Emitted/consumed events + topic ownership | Events & webhooks |
config, config_schema | Operator Settings UI + boot config keys | Settings & config |
lifecycle_hooks | Provision / deprovision / plan-change / config-reload / upgrade hooks | Lifecycle hooks |
scheduled_tasks | Cron jobs synced into the CP scheduler | Scheduled tasks |
notification_templates, notification_layouts | Templates + re-brandable email layouts | Notifications |
audit | Declarative audit-event catalogue | Audit |
gdpr, compliance | Export / erasure / consent + data classification | GDPR & compliance |
tenancy, deployment, compute, infrastructure, required_env, health, status, depends_on | Storage/isolation, topology, sizing, env wiring, health | Tenancy & deployment |
ai | Providers, agents, tools, skills, corpora, memory, evals, budgets | AI surface |
apis, workflow_categories | Declared internal API actions; workflow buckets | — (this page) |
One caution when reading platform code: the top-level manifests under egav-control-plane/backend/manifests/infra/* share this same apiVersion/kind shape but describe platform infrastructure components, not tenant apps — the per-service contract is always {service}/backend/app.manifest.yaml.
// $defs.Spec
{
"type": "object",
"additionalProperties": false,
"required": ["features"] // the only mandatory spec section
}