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 things you must NOT do inside a chart.
| Name | platform-tenant-contract |
| Version | 1 |
| 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
descriptionServiceAccount used by the pod. It carries no role-arn annotation: the eks-agent-platform operator creates an EKS Pod Identity association binding the tenant ServiceAccount to the per-Platform IAM role, whose scoped datastore-access policy the operator generates from Platform.spec.datastores. `serviceAccount.name` must match the tenant-runtime ServiceAccount the association targets.
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) 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: that provisioning is not yet wired end to end, so declare the stores but do not assume they exist at deploy time. 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. NOT YET WIRED END TO END, and build accordingly: the module and the IAM generation both exist, but nothing yet carries a Platform CR's spec.datastores into the module's var.tenants input, which is empty in development, staging and production. Declaring datastores is correct and forward-compatible and remains what you should do — but no declared store is provisioned in any environment today, so an app must not assume its database, bucket or queue exists at deploy time. 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 prefix plus iam:PassRole (Scheduler-service-capped) on an operator-minted <env>-<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.
do_not- Do NOT scaffold IAM roles inside the chart, and do NOT annotate the ServiceAccount with a role ARN. The eks-agent-platform operator provisions the per-Platform IAM role — with a datastore-access policy generated from spec.datastores — and creates the EKS Pod Identity association that binds the tenant ServiceAccount to it. The chart carries no role ARN.
- Do NOT hand-write a per-app landing-zone component for the tenant's databases, buckets, queues, caches, or streams. Declare them in Platform.spec.datastores — 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) still land in `nanohype/landing-zone` or `nanohype/eks-gitops`, never in-app tofu.
- Do NOT reference a hand-written managed policy through extraPolicyArns for SES or EventBridge Scheduler. Declare them in Platform.spec.identity.capabilities — the operator generates the scoped grants (and mints the scheduler-invoke role). extraPolicyArns stays the escape hatch only for grants outside both the datastore and capability vocabularies.
- Do NOT rely on a broad Secrets Manager grant for the tenant role. Secrets projected into the pod by the chart's ExternalSecret need no grant at all (the External Secrets controller reads them under its own identity); for the few a pod reads itself via the SDK, list them in Platform.spec.identity.directSecretReads so the operator grants read on exactly those.
- Do NOT add cluster-level addons in the chart (ingress controller, cert-manager, External Secrets, observability stack). Those are gitops-repo concerns in `nanohype/eks-gitops`.
- Do NOT skip per-env `values-{dev,staging,production}.yaml` — every chart has three deltas even if some are empty.
- Do NOT hardcode AWS account IDs, region names, or KMS key ARNs in chart values. Per-env values plumb them in from landing-zone outputs at deploy time.