Platform tenant contract
Every k8s-native deliverable on the nanohype stack ships as a Platform tenant — a self-contained unit the eks-agent-platform operator can scaffold, suspend (via kill-switch), and tear down via CR reconciliation. This contract names the artifacts a chart must produce, the minimum Platform CR shape, the OTel resource attributes the operator expects, and the rules a chart follows, each with its severity.
| Name | platform-tenant-contract |
| Version | 2 |
| Source | standards/platform-tenant-contract.json |
The standard
required_artifactspath- <app>/chart/Chart.yaml
description- Helm chart manifest.
path- <app>/chart/values.yaml
description- Base values shared across all environments.
path- <app>/chart/values-{dev,staging,production}.yaml
descriptionPer-env delta files. Required even if empty — tooling consistency depends on the presence of all three.
path- <app>/chart/templates/deployment.yaml (or statefulset.yaml)
description- The workload itself. Stateful workloads use StatefulSet; everything else uses Deployment.
path- <app>/chart/templates/service.yaml
description- ClusterIP Service exposing the workload to in-cluster consumers.
path- <app>/chart/templates/serviceaccount.yaml
descriptionReferences the operator-owned tenant-runtime ServiceAccount (`serviceAccount.create: false`, `serviceAccount.name: tenant-runtime`) rather than creating one. The eks-agent-platform operator creates that ServiceAccount in the workload namespace and an EKS Pod Identity association binding it to the per-Platform IAM role, whose scoped datastore-access policy the operator generates from Platform.spec.datastores. It carries no role-arn annotation.
path- <app>/chart/templates/networkpolicy.yaml
description- Default-deny NetworkPolicy plus explicit egress allow-list.
path- <app>/gitops/applicationset-entry.yaml
description- ApplicationSet entry to be registered with `nanohype/eks-gitops`.
path- <app>/platform.yaml
descriptionPlatform CR (and any required BudgetPolicy CR, plus the ModelGateway CR whose routes an app that calls a model names) declaring the tenant boundary AND its stateful substrate (spec.datastores). The CR objects live in the team namespace (metadata.namespace: tenants-<team>); the eks-agent-platform operator provisions a per-Platform WORKLOAD namespace named tenants-<platform-name> — where the app's chart deploys — plus its ResourceQuota, LimitRange, default-deny NetworkPolicy, the ArgoCD AppProject named <platform-name>, and the per-Platform IAM role with a datastore-access policy generated from spec.datastores. The declared datastores themselves are provisioned by the generic tenant-substrate module from that same declaration — there is no per-app substrate component. See datastore_vocabulary: landing-zone renders tenant-substrate's var.tenants from the Platform CRs each environment's tenants.selection.yaml names, so a declared store is provisioned in an environment only once its tenant is selected there. The app's ApplicationSet entry must target that per-Platform namespace + project.
path- <app>/agentfleet.yaml (AI workloads only)
descriptionOptional AgentFleet CR declaring one or more agents, each an image bound to a named ModelGateway route, which the operator runs as a Deployment behind a KEDA scaler. Required only when the app runs an AI agent fleet inside its tenant.
platform_cr_shapeapiVersion- platform.nanohype.dev/v1alpha1
kind- Platform
metadataname- <app-name>
namespace- tenants-<team>
specdisplayName- <human-readable>
persona<one of: sales-ops | support | finance | ops | founder | eng | marketing | legal | generic>
tenant- <team>
budgetname- <BudgetPolicy CR name in same namespace>
identityallowedModelFamiliesanthropic
extraPolicyArns- —
capabilities- —
directSecretReads- —
compliancesoc2truehipaafalse
isolation- namespace
datastoresname- <short-name>
kind- <one of: relational | keyValue | objectStore | queue | cache | stream>
deletionPolicy- Retain
datastore_vocabularysummaryDeclare the tenant's stateful stores in spec.datastores instead of hand-writing a landing-zone component. Each entry names a datastore and its kind; the generic tenant-substrate module provisions the resource and the operator generates the scoped IAM to reach it, so tenant count is unbounded. landing-zone renders each environment's tenant-substrate var.tenants from the Platform CRs that environment's tenants.selection.yaml names (scripts/render-tenants.py; CI fails on drift), so a declared store is provisioned in an environment only once its tenant is selected there. Declaring datastores is correct whether or not an environment selects the tenant, and an app must not assume its database, bucket or queue exists at deploy time in an environment that does not. Each kind carries at most the one typed config block matching it — omit it to take the young/light defaults. keyValue requires its block (a DynamoDB table has no default partition key); stream carries none.
kindsrelationalAurora PostgreSQL Serverless v2. Config: engineVersion, minACU/maxACU (string, 0.5-ACU steps), backupRetentionDays, deletionProtection.
keyValueDynamoDB. Config (required): partitionKey {name,type}; optional sortKey, billingMode, ttlAttribute, pointInTimeRecovery, globalSecondaryIndexes.
objectStore- S3. Config: versioning, lifecycleExpireDays.
queueSQS with an optional dead-letter queue. Config: fifo, visibilityTimeoutSeconds, messageRetentionSeconds, maxReceiveCount (>0 provisions the DLQ).
cache- ElastiCache (Valkey/Redis). Config: engine, nodeType, replicas.
stream- MSK Serverless with IAM auth. No config block.
deletiondeletionPolicy defaults to Retain: deleting the Platform CR orphans the datastore intact (it keeps the Tenant tag recording what owned it), and the operator holds no delete permission on any datastore. The per-kind deletion_protection backstop is a second gate, defaulting closed.
capability_vocabularysummaryManaged AWS capabilities outside the datastore vocabulary — SES send, EventBridge Scheduler — are declared in spec.identity.capabilities. The operator generates the scoped IAM from that declaration (a capability-access policy on the tenant role, and for eventBridgeScheduler a minted scheduler-invoke role), so these ride the declaration too instead of a hand-written managed policy referenced by ARN.
capabilitiessesGrants ses:SendEmail / ses:SendRawEmail scoped by a ses:FromAddress condition to the tenant's sending domain, plus ses:GetSendQuota. The verified sending identity is account-level mail infrastructure in landing-zone, not provisioned per app.
eventBridgeSchedulerGrants scheduler:*Schedule on the tenant's own schedule group, <env>-<platform>, which the operator creates, plus iam:PassRole (Scheduler-service-capped) on an operator-minted <cluster>-<platform>-scheduler-invoke role, which may SendMessage to the tenant's own queue datastores. Declare a queue datastore as the schedule's target.
secret_accesssummaryMost secret material reaches a tenant through the chart's ExternalSecret, projected by the cluster's External Secrets controller under its own identity — the tenant role needs no Secrets Manager grant for those. Only the secrets a pod resolves itself through the pod role via the AWS SDK are declared in spec.identity.directSecretReads, and the operator grants secretsmanager:GetSecretValue / DescribeSecret on exactly those.
directSecretReadsA list of secret names under the tenant's own <platform>/<env>/ prefix (e.g. "grafana/oncall-webhook-hmac"). Use it for the values a pod reads directly rather than by ExternalSecret projection — rotation-sensitive secrets a handler re-fetches and caches by version, or config bulk-loaded at startup. Each entry scopes to arn:...:secret:<platform>/<env>/<name>-*; leaving it empty means the tenant role holds no Secrets Manager grant at all.
otel_resource_attrsname- agents.tenant
requiredtruedescription- The owning team. Matches Platform.spec.tenant.
name- agents.platform
requiredtruedescription- The Platform CR name. Used by dashboards to slice by app.
name- agents.model_family
requiredfalsedescriptionAI workloads only. One of: anthropic, amazon, meta, mistral, etc. Drives cost attribution and guardrail routing.
name- agents.model_id
requiredfalsedescription- AI workloads only. The full Bedrock model ID being invoked.
rulesid- no-chart-iam-role
summaryThe chart scaffolds no IAM role or policy. The eks-agent-platform operator provisions the per-Platform IAM role, with a datastore-access policy generated from spec.datastores; a role the chart creates is one no Pod Identity association binds.
severity- reject
id- no-chart-serviceaccount
summaryThe chart creates no ServiceAccount of its own. It references the operator-owned tenant-runtime ServiceAccount (serviceAccount.create: false, name: tenant-runtime), which is the one the operator's Pod Identity association targets; a chart-created ServiceAccount gets no association and its pods get no AWS credentials.
severity- reject
id- no-role-arn-annotation
summaryNo ServiceAccount the chart renders or references carries an eks.amazonaws.com/role-arn annotation. Identity is EKS Pod Identity, bound by the operator; the annotation adds IRSA web-identity credentials to the pod, and the tenant role trusts only pods.eks.amazonaws.com, never sts:AssumeRoleWithWebIdentity.
severity- reject
id- declare-datastores
summaryThe tenant's databases, buckets, queues, caches and streams are declared in Platform.spec.datastores, never in a hand-written per-app landing-zone component. The generic tenant-substrate module provisions them and the operator generates the scoped IAM. Cloud-substrate gaps the datastore vocabulary does not cover (a shared VPC, base IAM, a KMS key, a new addon) land in `nanohype/landing-zone` or `nanohype/eks-gitops`, never in-app tofu.
severity- warn
id- declare-capabilities
summarySES send and EventBridge Scheduler are declared in Platform.spec.identity.capabilities, never granted by a hand-written managed policy referenced through extraPolicyArns. The operator generates the scoped grants and mints the scheduler-invoke role. extraPolicyArns is the escape hatch only for grants outside both the datastore and capability vocabularies.
severity- warn
id- scoped-secret-reads
summaryThe tenant role holds no broad Secrets Manager grant. Secrets the chart's ExternalSecret projects need no grant (the External Secrets controller reads them under its own identity); the few a pod reads itself through the SDK are listed in Platform.spec.identity.directSecretReads, and the operator grants read on exactly those.
severity- warn
id- no-cluster-addons
summaryThe chart adds no cluster-level addon (ingress controller, cert-manager, External Secrets, observability stack). Addons are gitops-repo concerns in `nanohype/eks-gitops`.
severity- warn
id- per-env-values
summaryEvery chart carries all three per-env deltas, `values-{dev,staging,production}.yaml`, even when some are empty.
severity- warn
id- no-hardcoded-account-values
summaryChart values hardcode no AWS account ID, region name or KMS key ARN. Per-env values plumb them in from landing-zone outputs at deploy time.
severity- warn