ADR-0156: Tenant hierarchy, common fields stamped by PostgreSQL and a separate runtime login
Context
The architecture reference places organizations and business units under the tenant (A6.1 core, A6.2 "tenant to organisations, projects, environments"), asks for the common fields environment_id, updated_by, version, validated metadata and correlation_id on the business tables (B7.2), and separates the administrator and runtime connections (B7.3).
Before this decision eai.organizations and eai.business_units did not exist, the environment was only a text column (usage_events.environment, application_deployments.environment), updated_by and version were carried by a few tables only, metadata was validated nowhere in the database, and the API and the migrations used the same login (EAI_DATABASE_URL).
Decision
- Hierarchy (V162):
eai.organizations(tenant, slug unique per tenant, statusactiveorarchived) andeai.business_units(organization, optionalparent_idfor nesting, fixed at creation so that no cycle can appear). A project is attached to an organization and one of its units (projects.organization_id,projects.business_unit_id). The foreign keys are composite withtenant_idororganization_id: a foreign key check bypasses RLS, so only a composite key prevents, even in the platform context, attaching a project or a unit to another tenant's organization. Forced RLS on both tables. - Tenancy routes: GET and POST
/v1/organizations, PATCH/v1/organizations/{organization_id}, GET and POST/v1/business-units, PATCH/v1/business-units/{business_unit_id}, plusorganization_id,business_unit_idandmetadataon projects. tenant_admin writes, any tenant role reads; every write is audited. - Common fields stamped by the database (V162, V164): the trigger
eai.stamp_common_fields()writescreated_byandupdated_byfrom the verified actor (app.actor_id), incrementsversionon every update and writestrace_id(thecorrelation_idof the reference, the name already used byaudit_events,usage_eventsandtelemetry_spans) fromapp.correlation_id, whicheai.db.txsets from the W3C trace of the request. It applies toorganizations,business_units,projects,environments,agents,applicationsandincidents. A caller can neither choose its version nor erasecreated_by. PATCH routes acceptexpected_version: a stale version is 409version_conflict. metadatais validated in the database byeai.valid_metadata()(CHECK constraint): flat object, lower case keys (^[a-z][a-z0-9_.-]{0,62}$), text (1024 characters maximum), number, boolean or null values, 16 KB maximum. A CHECK constraint was preferred to a JSON Schema extension (pg_jsonschema): no extension to install at the client, and the rule holds for every writer, the API as well as a script. A refused metadata is 422invalid_metadata.environment_id(V163):usage_eventsandapplication_deploymentscarry a foreign key toeai.environments(ON DELETE SET NULL), computed in the database by a trigger from (tenant, project by id or slug, environment name). The text column stays for the rows whose project has no such environment:environment_idis then NULL, never a guess. A value supplied by the writer is ignored and recomputed inside the row's tenant.- Runtime login (V165): the group role
eai_runtime(NOLOGIN, member ofeai_app, no CREATE on any schema, owner of nothing, read only onschema_migrations). The operator creates the API login (CREATE ROLE eai_api LOGIN PASSWORD '...' IN ROLE eai_runtime) and gives it inEAI_RUNTIME_DATABASE_URL: the request pool and the worker use it;eai.migratealways keepsEAI_DATABASE_URL, the administrator login. WithoutEAI_RUNTIME_DATABASE_URLthe previous behaviour stays (one login, RLS throughSET LOCAL ROLE eai_app), so that no installation breaks; the production startup check requires TLS on both URLs.
Consequences
- Responses for projects, environments, agents, applications and incidents gain
created_by,updated_by,version,metadataandtrace_id. - A client writing concurrently on the same project or organization can pass
expected_versionto never overwrite a change. - The key
EAI_RUNTIME_DATABASE_URLis added to the Control Plane OpenBao secret when the operator creates the runtime login; the chart does not require that key yet, so that the sync of an existing secret is not blocked.
Evidence
services/control-plane/tests/test_tenancy_hierarchy.py (hierarchy, isolation between tenants down to the composite keys, stamped fields, optimistic concurrency, metadata refused in the database, environment_id resolved and recomputed, runtime login without DDL serving the API while the migration refuses it), services/control-plane/tests/test_data_model_mapping.py, services/control-plane/tests/test_security_attack_suite.py, and the request shape tests of the four SDKs.