KUA Application Contract
Tracking: #149, part of #146. Code: lib/kua/applicationContract.js (contract), lib/kua/applicationScopes.js (migration and report).
A KUA Application (KUApp) is the context that owns identity, provider scopes, resources and architecture views. It has no provider and no profile of its own: one application can hold AWS accounts, GCP projects, Kubernetes contexts, Vercel teams and, later, plugin providers.
Application context (version 1)
{
"contractVersion": 1,
"id": "app-orders",
"name": "Orders",
"environment": "production",
"team": "Platform",
"revision": 12,
"scopes": [
{ "key": "kua-scope:…", "provider": "aws", "scopeId": "111111111111", "location": "us-east-1", "label": "Orders account" },
{ "key": "kua-scope:…", "provider": "kubernetes", "scopeId": "eks-orders-prod", "location": "" }
],
"views": { "architectureProjectIds": ["project-orders", "project-orders-data"] }
}nameis required and is not an identity: two applications can share a name, and theidtells them apart.- An application with no scopes is valid (an empty KUApp to which resources are added later).
scopes[]is portable.scopeIdis the AWS account, GCP project, Kubernetes context or Vercel team;locationis the region or location when the provider has one. An emptyscopeIdmeans the scope has not been verified yet.provideris an open id (^[a-z][a-z0-9-]{1,39}$). KUA ships withaws,gcp,vercel,kubernetesandgeneric; plugins (#156) can add others, such aszabbix,githubordatadog, without a schema change.- Scopes with the same provider,
scopeIdandlocationcollapse into one. AnyprofileIdpassed with a scope is dropped.
lib/kua/applicationContract.js exports three examples (EXAMPLES.empty, EXAMPLES.kubernetesOnly, EXAMPLES.multiScope) that the tests validate.
Local bindings: profiles never travel
A profile is a credential on one computer. Each computer binds its own profile to a scope:
binding (local only) = { scopeKey, profileId, status }Bindings are never part of the context, a KUAAppBundle, sync, team sharing or the Control Plane. When an application is imported or shared, each person binds their profiles to its scopes. A scope without a binding is reported as such, and its resources stay visible. Before a binding is trusted, KUA must check that the session matches the scope: the AWS account from sts:GetCallerIdentity (no charge), the configured GCP project, an existing kube context, or the Vercel team.
Resource identity v2
["v2", provider, scopeId, location, resourceType, nativeIdentifier] (lower-cased)
id = "kua-resource:" + sha256(identityKey)[0..24]The identity does not include the profile or the display name. The same resource therefore has the same id on every computer, and an exported id does not reveal a credential name. The same native identifier in different scopes (for example a deployment in staging and in production) keeps two identities. Relationship ids are built from the portable ids of their two resources and their type.
The local registry (kua_registry_resources) uses identity v2 too. At startup, KUA drops the rows still keyed by identity v1 and reconciles every application again. The registry is derived from APM resources and architecture graphs, where human decisions live, so nothing is lost; graphs get one registry.reconcile revision with the new ids. Resources that v1 kept apart only by profile become one, and each merge is recorded for the migration report.
Legacy mapping
legacyApplicationContext(application, { resources, architectureProjectIds }) in lib/kua/applicationScopes.js maps an existing APM application (one provider, profile and region) to the contract without losing anything:
| Today | Contract |
|---|---|
provider + region | primary scope, scopeId empty (unverified) unless a resource names it |
| resources' ARN account/region, kube context | one scope per distinct provider scope |
| resources of the application's provider without an account (no ARN, global S3 bucket) | the primary scope |
generic provider | a scope only while the application has no resources |
profile_id | local binding of the scopes of the application's own provider (migrated, or unverified when scopeId is empty) |
architecture_project_id and the project link table | views.architectureProjectIds |
A Kubernetes workload inside an AWS application gets a Kubernetes scope with no binding: it is reached through its kube context, not the AWS profile.
Storage
| Table | Travels | Content |
|---|---|---|
apm_applications | yes (no profile) | identity, revision; provider, profile_id and region are now optional and only describe a legacy application |
kua_application_scopes | yes | portable scopes per application |
kua_scope_bindings | never | scope → local profile, with status (migrated, unverified, verified, mismatch) and the verified identity |
kua_registry_identity_merges | no | resources that identity v2 merged, for the report |
An application created without provider, profile or region is valid. It is not listed under any profile in Observability, and the scheduler does not collect it: collection per scope binding is #166. Application names are no longer unique. Adding or removing a scope or a view, or editing the identity, moves revision.
At every start, and whenever an application's resources change, legacy applications get the scopes their resources live in and a binding to their profile where none exists. A binding chosen by the user is never replaced, and scopes are never removed automatically.
API
/api/kua-apps/applications serves KUA Applications through lib/kua/applicationService.js. The MCP tools of #154 and #155 must use the same service. The API is not scoped by X-Profile-Id, because an application has no profile of its own.
| Method and path | Effect |
|---|---|
GET /applications | every application, legacy ones included |
POST /applications | create from { name, environment, team, scopes[] }, with no provider or profile |
GET /applications/:id | one application |
PATCH /applications/:id | edit name, environment, team |
DELETE /applications/:id | delete the application and its local data; architecture projects and live infrastructure stay |
POST /applications/:id/scopes | add a scope: 201 when new, 200 when it already existed |
DELETE /applications/:id/scopes/:scopeKey | remove a scope; 409 SCOPE_IN_USE while legacy resources live in it |
PUT /applications/:id/scopes/:scopeKey/binding | bind a local profile { profileId } and verify it |
POST /applications/:id/scopes/:scopeKey/binding/verify | verify the binding again |
DELETE /applications/:id/scopes/:scopeKey/binding | remove the binding |
Writes that change the application accept expectedRevision, in the body or the query. A stale value answers 409 REVISION_CONFLICT with the current revision, and nothing is written. Bindings are local, so they do not move the revision.
A response has the contract fields, warnings (scope_unbound, scope_unverified, scope_mismatch, duplicate_name) and local. local holds the bindings and the legacy provider/profile/region, and must never be exported or synced.
Verification
Verification only uses reads that have no charge:
| Provider | Read | Verified when |
|---|---|---|
| AWS | sts:GetCallerIdentity | the account equals scopeId |
| GCP | the project of the gcloud configuration or service account | the project equals scopeId |
| Vercel | the team of the stored profile | the team equals scopeId |
| Kubernetes | the kube contexts of this computer | the context exists (context names differ between computers) |
| others (plugins) | none | stays unverified |
A different identity is mismatch. A failed read (expired session, missing profile) leaves the binding unverified with its error and never reports a mismatch. A scope without scopeId is completed with the identity the profile reveals: the scope is replaced and the binding moves with it, and legacy synchronization does not bring the pending scope back.
KUApps shows these applications with an Accounts and scopes panel (frontend/src/components/kuapps/KUAppScopes.vue) that adds and removes scopes, binds a profile of this computer to each one and shows the verification result. Architecture and Observability still need one profile, so for an application without a provider they open once resources can be added to its scopes (#151). Team publishing skips these applications until the bundle carries scopes (#153). A link with ?app=<id> opens an application in KUApps.
Migration report
GET /api/kua-apps/migration-report is read-only and lists what needs a decision. It names applications, views and scopes, never profiles.
| Finding | Severity | Suggested resolution |
|---|---|---|
scope_mismatch | error | bind a profile whose session matches the scope |
scope_unbound | warning | bind a local profile |
broken_view_link | warning | unlink the missing architecture project |
view_profile_mismatch | warning | the project belongs to another profile; reconciliation skips it today |
view_shared_by_applications | warning | review which application owns the view |
registry_identity_v1 | warning | reconcile (startup does it) |
scope_unverified | info | verify the scope against the session |
application_without_view / view_without_application | info | create or link a view |
duplicate_name | info | optional rename; the id is the identity |
resource_identities_merged | info | none, recorded for traceability |
Export (KUAAppBundle)
The bundle carries portable data only:
registry.resources[]uses identity v2:sourceId,identityKeyandidentityVersion: 2. The localidand the v1identityKeyare never copied. Both are recomputed on import, so an older bundle loses its v1 keys when it is read again.registry.relationships[]point to portable resource ids. A relationship to a resource that is not in the bundle is left out.architecture.changes[].authorislocalwhen the author was a local profile.
Older versions exported identityKey with the profile id in plain text. Cloud backups and team copies made before this change keep that value until the application is uploaded again.
Restoring registry membership on import, several architecture projects per bundle, and the report-style export (Advisor, resource references, routes and communications) are part of #153.
