Skip to content

Cloud Inventory

This is the authoritative bounded-context reference for the Cloud Inventory sub-context that ships under internal/provisioning/cloud/. Cloud Inventory is the sub-context of plexsphere provisioning that owns the connection metadata for the cloud-provider accounts the platform provisions substrate into. The Cloud aggregate is the inventory row; its per-provider endpoint and region-default JSON blobs are validated at the application boundary by a per-provider validator family before the aggregate constructor runs. Every aggregate state change appends a typed domain event to the shared platform outbox inside the same Postgres transaction as the row mutation.

Cross-references

  • ../../contributing/layout.md — the bounded-context map row that locates internal/provisioning/cloud inside the codebase and enumerates the depguard rules that keep the domain layer free of pgx, sqlc-generated row types, and provider SDKs.
  • ./credential-pool.md — the sibling Cloud Credentials Custodian. A CloudCredential references a Cloud row by cloud_id with ON DELETE RESTRICT; the empty-aggregate guard documented below blocks a Cloud delete while non-expired credentials still reference it.
  • ./rebac.md — the Credential Assignment sub-context and its ReBAC contract. The CloudCreated event seeds the per-object cloud:<id>#cloud_admin grant from the creating principal so the creator gains manage and observe on the new Cloud — it can both administer and read it back.
  • ../../reference/api/clouds.md — the per-operation /v1/clouds HTTP reference: request/response schemas, ReBAC gate, and the closed Problem.code taxonomy.
  • ../../reference/api/provider-bundles.md — the same reference for /v1/provider-bundles, the surface the ProviderBundle aggregate is authored through.
  • ../../../internal/provisioning/cloud/doc.go — package-level pin of the ubiquitous language and the framework-free layering rationale.
  • ../../../internal/platform/db/migrations/0020_clouds.sql — the persistence schema for plexsphere.clouds and the count_cloud_credentials_for helper that backs the empty-aggregate guard.

Pages

This bounded-context reference is intentionally a single page. The sub-context's surface area is narrow — two aggregates, three value objects, a validator family, six events — and the pieces travel in lockstep. Cross-cutting code anchors are linked inline from each section.

Ubiquitous language

The terms below travel together across the Go code, the SQL migration, the outbox event payloads, and operator-facing tooling. Names are preserved verbatim in error messages and outbox payloads so a reader chasing a string from a log line finds it in the source without translation.

TermDefinitionCode anchor
CloudThe aggregate root. One row in plexsphere.clouds carrying the connection metadata for a single cloud-provider account: (id, display_name, slug, provider, external_id, endpoint, region_defaults, provider_config_api_version, provider_bundle_id, provider_bundle_version, created_at, updated_at), plus the child rows in plexsphere.cloud_provider_packages that hold the declared packages. Every CloudCredential and downstream provisioning job refers back to a Cloud by id.cloud/cloud.go
IDThe 16-byte UUIDv7 primary key of the Cloud row. The String() projection is the canonical 8-4-4-4-12 hyphenated form. The zero value is treated as "not yet assigned": NewCloud auto-assigns it, Hydrate rejects it.cloud/types.go
DisplayNameThe human-readable name rendered in dashboard topbars, breadcrumbs, and table cells. Non-empty after trimming, capped at MaxCloudDisplayNameLen = 256 bytes.cloud/cloud.go
SlugA kebab-case value object used as the Cloud's URL handle and partition key. Matches ^[a-z0-9]+(-[a-z0-9]+)*$, capped at 63 characters so it fits a single DNS label. Leading/trailing whitespace is rejected, not trimmed. The aggregate exposes no Reslug method — the slug is immutable.cloud/types.go
ProviderThe closed-enum discriminator that routes per-provider validation. The supported taxonomy is {aws, azure, gcp, hetzner, openstack} — one entry per infrastructure substrate a Blueprint version can target. The match is case-sensitive lowercase — silently lower-casing would mask operator typos. The aggregate exposes no ChangeProvider method — the provider is immutable.cloud/types.go
ExternalIDThe upstream provider's stable account identifier (an AWS account number, an Azure subscription id). Non-empty after trimming. The (provider, external_id) UNIQUE constraint is the structural guarantee that one provider account is not registered as two distinct Clouds.cloud/cloud.go, 0020_clouds.sql
EndpointAn opaque JSONB blob whose shape is owned by the per-provider validator family. The aggregate enforces only "non-empty + valid JSON"; the per-provider validator owns the field-level schema. Stored as jsonb so a validator evolution needs no column migration.cloud/cloud.go, cloud/validator/
RegionDefaultsAn opaque JSONB blob the provisioning layer overlays onto per-resource requests. Same aggregate-level contract as Endpoint — "non-empty + valid JSON", per-provider shape owned by the validator.cloud/cloud.go
ProviderPackageOne Crossplane provider package the Cloud declares, as a value object of two operator-supplied parts: the OCI source (repository without tag or digest) and the pinned version (an OCI tag, optionally with a @sha256 digest, the combined form Crossplane's spec.package consumes). A Cloud in inline mode carries an ordered set of these, held in plexsphere.cloud_provider_packages keyed by (cloud_id, package_source) and rendered source-ascending; a Cloud in bundle mode carries none of its own and takes the set from the bundle version it pins. The Management Fleet installs every declared package on each management cluster hosting a Project this Cloud is assigned to (see Provider packages). For a member of a maintained provider family (provider-<family>-<service>), the fleet also converges that family's shared package alongside the member, pinned tag-only (see What the installer applies). Because the fleet converges a cluster's Provider object to exactly source:version, a version recorded here is what actually runs: a bare tag where the cluster's manifest carries a digest pin makes every sweep rewrite it.cloud/types.go
ProviderConfigAPIVersionThe <group>/<version> every package the Cloud declares serves its ProviderConfig under, e.g. aws.m.upbound.io/v1beta1. It sits on the Cloud rather than on each package because the broker renders one ProviderConfig per Cloud, and the packages a Cloud declares belong to one provider family that serves one group. In inline mode it is required at creation and patchable on its own; on a bundle-mode Cloud it is absent, a patch naming it alone is refused, and naming it together with the package set is what leaves bundle mode. The Provisioning Broker stamps the Cloud's effective value as the rendered ProviderConfig's apiVersion.cloud/types.go
ProviderBundleThe second aggregate root. One row in plexsphere.provider_bundles naming one reusable Crossplane provider-package declaration. The row carries identity only — (id, display_name, slug, provider, created_at, updated_at, version) — and the declaration itself lives in the version rows below. The bundle is authored once and addressed by its slug instead of being restated on every Cloud that needs it. A bundle holds no Endpoint and no RegionDefaults, so no per-provider validator runs for it.cloud/providerbundle.go
BundleIDThe 16-byte UUIDv7 primary key of the ProviderBundle row. It is a distinct Go type from ID, not an alias, so a bundle id cannot compile into a cloud id's position. The zero value is treated as "not yet assigned": NewProviderBundle auto-assigns it, HydrateProviderBundle rejects it.cloud/providerbundle.go
Published versionOne immutable declaration of a bundle: a row in plexsphere.provider_bundle_versions keyed (bundle_id, version_number) carrying that version's provider_config_api_version, the principal that published it, and its timestamp, plus the package rows in plexsphere.provider_bundle_version_packages. version_number counts from 1 within a bundle, so a version is addressed the way an operator names it. A published version is never rewritten: publishing appends.0081_provider_bundle_versions.sql
LatestVersionNumberThe content version of the newest declaration a bundle holds — the number PublishVersion mints, the number a Cloud attaching without an explicit pin takes, and the number the wire carries as latest_version. It is not the aggregate's Version; see Two counters.cloud/providerbundle.go
ProviderBundleIDThe Cloud's reference to the ProviderBundle it takes its provider configuration from, held in plexsphere.clouds.provider_bundle_id. The zero value means the Cloud declares its packages inline instead. The composite foreign key (provider_bundle_id, provider) into plexsphere.provider_bundles (id, provider) with ON DELETE RESTRICT states two properties as one constraint: the referenced bundle exists, and it belongs to the Cloud's own provider.cloud/cloud.go, 0080_clouds_provider_bundle_ref.sql
ProviderBundleVersionThe Cloud's pin: which published version of the referenced bundle it reads its declaration from, held in plexsphere.clouds.provider_bundle_version. It travels with ProviderBundleID — bundle mode carries both, inline mode neither — and a second composite foreign key (provider_bundle_id, provider_bundle_version) into plexsphere.provider_bundle_versions (bundle_id, version_number) states in one constraint that the pinned version exists and belongs to the named bundle.cloud/cloud.go, 0081_provider_bundle_versions.sql
Inline modeThe Cloud state in which the Cloud holds the declaration itself: ProviderPackages and ProviderConfigAPIVersion are populated, and ProviderBundleID and ProviderBundleVersion are both zero.cloud/cloud.go
Bundle modeThe Cloud state in which the Cloud names a ProviderBundleID and a ProviderBundleVersion and holds no declaration of its own: ProviderPackages() is empty and ProviderConfigAPIVersion() is empty. The pinned version carries both, and a reader takes them from EffectiveProviderConfigResolver.cloud/cloud.go, services/effective_provider_config.go
ValidatorThe per-provider payload-shape port. Validate(provider, endpoint, regionDefaults) dispatches to the Validator registered for the provider and returns either a []FieldError list (mapped to Problem.errors[] on a 400) or ErrUnknownProvider. Validators are looked up via a process-global registry; one file per supported provider registers itself via init().cloud/validator/registry.go
CloudRepoThe aggregate-shaped persistence port. Carries Create, Get, List, Update, Delete. The Postgres adapter is the only spot that imports pgx; constraint-name dispatch maps SQLSTATE 23505 collisions onto the canonical sentinels.cloud/repo/cloud_repo.go

The Cloud aggregate

The Cloud aggregate is the only aggregate this sub-context owns. The aggregate root is the plexsphere.clouds row. Aggregates in this package are framework-free: they hold invariants and value types only, so the transport tier and the per-provider validator family operate on already-shaped values.

Construction

ConstructorUseID / timestamps
NewCloud(CloudParams)Construct a fresh Cloud at the application boundary.A zero ID triggers UUIDv7 auto-assignment; zero CreatedAt / UpdatedAt default to time.Now().UTC().
Hydrate(CloudParams) / HydrateCloud(CloudParams)Reconstruct a Cloud from a persisted row.A zero ID, CreatedAt, or UpdatedAt is rejected — a row coming out of the database is expected to have them populated, and defaulting silently would mask a corrupt row.

Both paths funnel through one private buildCloud so every invariant is enforced once, centrally. Endpoint and RegionDefaults are defensively copied in and out so a caller mutating its own slice after construction cannot reach into the aggregate's private state.

Invariants

The Cloud aggregate enforces every Build-time invariant locally. The SQL schema in 0020_clouds.sql carries the matching constraint as defence-in-depth.

InvariantEnforced atFailure mode
DisplayName non-empty after trimming, ≤ 256 bytes.buildCloud / Rename.ErrInvariant; the transport layer maps it to 400 invalid_cloud.
Slug is lowercase kebab-case matching ^[a-z0-9]+(-[a-z0-9]+)*$, ≤ 63 characters, no leading/trailing whitespace.ParseSlug + the slug CHECK on plexsphere.clouds.ErrInvariant.
Slug is unique.SQL UNIQUE clouds_slug_unique.repo.ErrCloudSlugConflict409 cloud_slug_conflict.
Provider is one of the closed taxonomy {aws, azure, gcp, hetzner, openstack}.ParseProvider + the provider CHECK on plexsphere.clouds.ErrInvariant.
(provider, external_id) is unique — one provider account is one Cloud.SQL UNIQUE clouds_provider_external_id_unique.repo.ErrCloudExternalIDConflict409 cloud_external_id_conflict.
ExternalID non-empty after trimming.buildCloud.ErrInvariant.
Endpoint non-empty and valid JSON.buildCloud / ChangeEndpoint (+ the per-provider validator for field-level shape).ErrInvariant; per-provider shape failure surfaces as 400 invalid_cloud_endpoint.
RegionDefaults non-empty and valid JSON.buildCloud / ChangeRegionDefaults (+ the per-provider validator).ErrInvariant; per-provider shape failure surfaces as 400 invalid_cloud_region_defaults.
The provider configuration is stated in exactly one way: ProviderBundleID names a bundle, or ProviderPackages and ProviderConfigAPIVersion declare it inline. A Cloud naming both is refused; a Cloud naming neither falls to the inline rules below.buildCloud + the clouds_provider_mode_check CHECK on plexsphere.clouds.ErrInvariant400 invalid_cloud.
A bundle reference names a concrete version: ProviderBundleVersion is at least InitialProviderBundleVersionNumber in bundle mode and zero in inline mode. A bundle without a pin says nothing about which declaration the Cloud converges with; a pin without a bundle names a declaration nothing can resolve.buildCloud + the widened clouds_provider_mode_check, whose two arms now each state the pin column.ErrInvariant400 invalid_cloud.
The pinned version exists and belongs to the referenced bundle.The composite foreign key clouds_provider_bundle_version_fkey. The aggregate does not know which versions a bundle holds, so this one rule lives in the schema alone.repo.ErrProviderBundleVersionNotFound400 provider_bundle_version_not_found.
ProviderPackages declares between one and sixteen packages; an empty set and an oversized one are both rejected.ParseProviderPackages in buildCloud / ChangeProviderPackages.ErrInvariant400 invalid_cloud.
Each ProviderPackage is a valid pair: OCI-repository source, tag-with-optional-digest version.ParseProviderPackage + the two non-empty CHECKs on plexsphere.cloud_provider_packages.ErrInvariant400 invalid_cloud.
A source appears at most once in the set.ParseProviderPackages + the (cloud_id, package_source) PRIMARY KEY on plexsphere.cloud_provider_packages.ErrInvariant400 invalid_cloud.
The set is held in canonical source-ascending order, whatever order the operator stated it in.ParseProviderPackages sorts before it returns, so every read surface renders the same sequence.Normalisation rather than a rejection.
ProviderConfigAPIVersion is a <group>/<version> value.ParseProviderConfigAPIVersion in buildCloud / ChangeProviderConfigAPIVersion + the non-empty CHECK on plexsphere.clouds.ErrInvariant400 invalid_cloud.
In bundle mode ProviderPackages() is empty and ProviderConfigAPIVersion() is the empty string.buildCloud runs the two inline parses only while ProviderBundleID is zero; ReferenceProviderBundle clears both values it replaces.Not a rejection: the values a bundle-mode Cloud converges with are the referenced bundle's, read through EffectiveProviderConfigResolver.
ProviderPackageOverrides is a bundle-mode value: a Cloud declaring its packages inline states none. An override deviates from a referenced declaration, and an inline Cloud names its versions in the set it owns.buildCloud on both paths + ChangeProviderPackageOverrides. A CHECK reads only the row it is attached to, so plexsphere.cloud_provider_package_overrides cannot see the Cloud's mode and the schema does not restate the rule.ErrInvariant400 invalid_cloud_provider_mode.
The override set holds at most sixteen entries, each a valid source/version pair, each source named at most once, in canonical source-ascending order. The empty set is legal and means the Cloud takes the pinned version as it stands.ParseProviderPackageOverrides + the (cloud_id, package_source) PRIMARY KEY and the two non-empty CHECKs on plexsphere.cloud_provider_package_overrides.ErrInvariant400 invalid_cloud; the ordering is a normalisation rather than a rejection.
An override replaces the version of the pinned member naming the same source, or joins the set when the pinned version carries no such source. It never removes a package, so the effective set is never smaller than the pinned declaration's.ApplyProviderPackageOverrides, the one merge rule every reader shares.Not a rejection: no override set an operator can state drops a package.
A provider family package in the override set is pinned by tag alone, never by digest.NewCloud / ChangeProviderPackageOverrides only. HydrateCloud does not apply it, on the reasoning the ProviderBundle's family-pin rule records below.ErrInvariant400 invalid_cloud.
ID non-zero when hydrating from persistence.buildCloud strict path.ErrInvariant.

Every invariant failure wraps the package sentinel ErrInvariant, so callers branch on errors.Is(err, cloud.ErrInvariant) without parsing strings.

Mutators

A Cloud is a value object: each mutator returns a new Cloud value and bumps UpdatedAt unconditionally — even on a caller-side no-op like Rename(c.DisplayName(), now). The "did the value actually change?" short-circuit lives at the service layer, so the aggregate stays free of that branching.

MutatorChangesNotes
Rename(name, now)DisplayNameRe-validates non-empty + length.
ChangeEndpoint(endpoint, now)EndpointRe-validates non-empty + valid JSON; the per-provider validator re-runs at the service layer when the field changes.
ChangeRegionDefaults(regionDefaults, now)RegionDefaultsSame contract as ChangeEndpoint.
ChangeProviderPackages(params, now)ProviderPackagesReplaces the whole set as one unit: the packages the caller names become the Cloud's packages and every package it omits is dropped. Re-validates each pair, the non-empty set, and source uniqueness; the per-provider validator does not run for it. An inline-mode operation: a Cloud in bundle mode has no set of its own to edit and is rejected.
ChangeProviderConfigAPIVersion(value, now)ProviderConfigAPIVersionChanges the Cloud-level apiVersion on its own, without restating the package set. An inline-mode operation, rejected in bundle mode for the same reason ChangeProviderPackages is.
ChangeProviderPackageOverrides(params, now)ProviderPackageOverridesReplaces the whole override set as one unit: the entries the caller names become the Cloud's overrides, every entry it omits is dropped, and the empty set clears the deviations and puts the Cloud back on the pinned version as it stands. Re-validates each pair, the set size, source uniqueness, and the family-pin rule. A bundle-mode operation: a Cloud declaring its packages inline has no referenced declaration to deviate from and is rejected, and changing a version there is ChangeProviderPackages.
ReferenceProviderBundle(id, versionNumber, now)ProviderBundleID, ProviderBundleVersion, ProviderPackages, ProviderConfigAPIVersion, ProviderPackageOverridesPoints the Cloud at version versionNumber of the bundle id and clears the inline package set and the inline apiVersion, so the Cloud states its configuration one way. A Cloud already referencing a bundle is re-pointed at the new one. A zero id is rejected: a Cloud that references nothing is a Cloud that declares its packages inline. A versionNumber below InitialProviderBundleVersionNumber is rejected for the matching reason: the reference is only complete once it names which declaration the Cloud converges with. The override set survives a call naming the bundle the Cloud already references and is cleared by one naming a different bundle: an override names a source out of the referenced bundle's vocabulary, and re-stating the current reference is how a Cloud moves its pin through this method.
PromoteProviderBundleVersion(versionNumber, now)ProviderBundleVersionMoves the pin within the bundle the Cloud already references, in either direction — a rollback to an earlier version is the same operation as an advance to a later one. It leaves the override set untouched, so the deviations the operator authored travel with the Cloud onto the new declaration. A bundle-mode operation: a Cloud declaring its packages inline has no bundle whose versions it could pin and is rejected, and stating a bundle is ReferenceProviderBundle, which names the bundle and the version together. The aggregate does not know which versions the bundle published, so a version it never did is refused one layer down by the composite foreign key.
DeclareInlinePackages(params, apiVersion, now)ProviderPackages, ProviderConfigAPIVersion, ProviderBundleID, ProviderBundleVersion, ProviderPackageOverridesStates the inline declaration whole — the package set and the apiVersion in one call — and clears the bundle reference together with its version pin and the override set. It is how a Cloud leaves bundle mode, and the two values travel together because a Cloud leaving the bundle has neither, and taking one without the other would build an aggregate no constructor accepts. The overrides go with the reference: params is the whole declaration, and an inline Cloud has no referenced packages left for an override to deviate from.

There is no Reslug mutator and no ChangeProvider mutator, by deliberate design. The slug is the URL handle exported into downstream caches, the dashboard router (where it appears in user-bookmarked URLs), and the outbox projection — exposing a reslug would rot every cached reference. The provider is the validator-routing key; changing it would invalidate every previously-stored endpoint blob because each provider has its own required-field shape. The operator-level semantics of "this Cloud is now an azure cloud" are equivalent to deleting and recreating the Cloud. The DECISION block next to the mutators in cloud.go records the trade-off; the slug-immutability rationale mirrors the tenancy.Domain DECISION block.

The provider enum

Provider is a closed-enum value object. The supported taxonomy is five values, one per infrastructure substrate a Blueprint version can target:

ProviderString formEndpoint requiresRegion defaults require
AWSawsregion, partitiondefault_region
Azureazurecloud_environmentsubscription_id, tenant_id
GCPgcpprojectdefault_region
Hetznerhetznerapi_endpointdefault_location
OpenStackopenstackauth_urldefault_region

ParseProvider validates an input string against the taxonomy and is case-sensitive: only the lowercase forms match. The closed set is held in one place (supportedProviders in types.go) and exposed through SupportedProviders() for Problem.detail hints and UI dropdowns. Adding a provider is a coordinated change: widen the provider CHECK in a numbered migration and add a validator file with a single Register call in the same change.

Each provider requires the field that discriminates one account or deployment from another, even where a public default exists. Hetzner's api_endpoint is required although the public API has a well-known URL, and Azure's cloud_environment likewise: the endpoint blob is what makes a Cloud pointing at a compatible private substrate expressible at all, and a validator admitting an empty object would turn a mistyped key into a Cloud silently addressing the public API. OpenStack does not require the Keystone scope (project_name, domain_name): an application credential carries its own scope, so requiring it here would duplicate or contradict it.

Relationship to Blueprint provider kinds

The Blueprint Catalog owns its own closed enum, ProviderKind, naming the substrates a Blueprint version accepts. The two value sets currently coincide, and they are still separate types owned by separate contexts: this one is a substrate registration and the validator-routing key, that one is what a template can target. Either may gain a member the other has no meaning for.

Provisioning compares them through an explicit correspondence table in the Provisioning Broker — the context that owns the comparison — rather than by string equality, which would be true only by coincidence. A declaration whose Blueprint version does not accept the kind corresponding to the Cloud's provider is refused at admission with 422 blueprint_provider_mismatch. See the Provisioning Broker reference.

Per-provider validator family

The aggregate-level invariant on Endpoint / RegionDefaults is only "non-empty + valid JSON". The field-level shape — which keys each provider requires, their value formats — is owned by the per-provider validator family in cloud/validator/. Validators are looked up by Provider via a process-global registry; Each supported provider registers itself at init() time from its own file so the closed taxonomy is enumerated in one place. The application service runs the validator before the aggregate constructor on Create and before persistence on Update — the typed field-error list flows into Problem.errors[] on a 400 response, and an unrecognised provider surfaces as 400 unknown_provider with KnownProviders() supplying the allowed-set hint.

The ProviderBundle aggregate

The ProviderBundle aggregate is the second aggregate this sub-context owns. Its root is the plexsphere.provider_bundles row. A bundle names one Crossplane provider-package declaration: the set of packages, and the apiVersion those packages serve their ProviderConfig under. The declaration is authored once and addressed by a stable handle, so an operator running several Clouds on the same provider family states the pins in one place. Like the Cloud, the aggregate is framework-free and holds invariants and value types only.

The package set and the apiVersion the aggregate holds are the content of its latest version. Every earlier version stays readable, because a Cloud pins one concrete version and reads that one's content rather than whatever the bundle declares now.

A bundle carries no Endpoint and no RegionDefaults, so the per-provider validator family has nothing to inspect and does not run on the bundle write path. Every value a bundle holds is validated by the aggregate itself.

Construction

ConstructorUseID / timestamps
NewProviderBundle(ProviderBundleParams)Construct a fresh ProviderBundle at the application boundary.A zero BundleID triggers UUIDv7 auto-assignment; a zero CreatedAt defaults to time.Now().UTC(), a zero UpdatedAt defaults to the resolved CreatedAt, a zero Version defaults to InitialProviderBundleVersion — the value migration 0079 declares as the column DEFAULT — and a zero LatestVersionNumber defaults to InitialProviderBundleVersionNumber, which is 1: a bundle that has never been patched still declares its first version.
HydrateProviderBundle(ProviderBundleParams)Reconstruct a ProviderBundle from a persisted row.A zero BundleID, CreatedAt, or UpdatedAt, a Version below InitialProviderBundleVersion, or a LatestVersionNumber below InitialProviderBundleVersionNumber is rejected, on the same reasoning HydrateCloud applies: a row coming out of the database is expected to have them populated, and defaulting silently would mask a corrupt row. A persisted bundle always carries at least one version row, so defaulting the number would hand a pinning Cloud a version the database does not hold.

Both paths funnel through one private buildProviderBundle, so every invariant is enforced once, centrally. ProviderPackages() hands back a copy of the declared set, so a caller mutating the returned slice cannot reach into the aggregate's private state.

Invariants

The ProviderBundle aggregate enforces every Build-time invariant locally. The SQL schema carries the matching constraint as defence-in-depth, on the bundle row in 0079_provider_bundles.sql and on the version rows in 0081_provider_bundle_versions.sql.

InvariantEnforced atFailure mode
DisplayName non-empty after trimming, ≤ 256 bytes (MaxProviderBundleDisplayNameLen).buildProviderBundle / Rename.ErrInvariant.
Slug is lowercase kebab-case matching ^[a-z0-9]+(-[a-z0-9]+)*$, ≤ 63 characters, no leading/trailing whitespace.ParseSlug + the slug CHECK on plexsphere.provider_bundles.ErrInvariant.
Slug is unique across the installation. A bundle belongs to no Domain.SQL UNIQUE provider_bundles_slug_unique.repo.ErrProviderBundleSlugConflict.
Provider is one of the closed taxonomy {aws, azure, gcp, hetzner, openstack}.ParseProvider + the provider CHECK on plexsphere.provider_bundles.ErrInvariant.
ProviderPackages declares between one and sixteen packages, each a valid source/version pair, each source named at most once, held in canonical source-ascending order.The set-level parse shared with the Cloud + the two non-empty CHECKs on plexsphere.provider_bundle_version_packages.ErrInvariant; the ordering is a normalisation rather than a rejection.
A provider family package (a source whose final path segment starts with provider-family-) is pinned by tag alone, never by digest.NewProviderBundle / PublishVersion only. HydrateProviderBundle does not apply it.ErrInvariant.
ProviderConfigAPIVersion is a <group>/<version> value.The apiVersion grammar shared with the Cloud + the non-empty CHECK on plexsphere.provider_bundle_versions.ErrInvariant.
BundleID, CreatedAt, and UpdatedAt non-zero, and Version at least InitialProviderBundleVersion, when hydrating from persistence.buildProviderBundle strict path + the version CHECK on plexsphere.provider_bundles.ErrInvariant.
LatestVersionNumber is at least InitialProviderBundleVersionNumber, which is 1.buildProviderBundle + the provider_bundle_versions_version_number_check CHECK, declared as version_number >= 1.ErrInvariant.

Every invariant failure wraps the same package sentinel ErrInvariant the Cloud raises, so callers branch on errors.Is(err, cloud.ErrInvariant) without parsing strings.

Four of these rules are not a second copy of the Cloud's: ParseSlug, ParseProvider, the package-set parse, and the ProviderConfig apiVersion grammar are the functions the Cloud runs, called with the aggregate name the rejection is reported against so an operator is pointed at the surface they wrote to. A bundle therefore cannot accept a slug, a provider, a package set, or an apiVersion a Cloud rejects, and a change to one of those grammars lands on both aggregates at once.

The family-pin rule is the one rule the two construction paths do not share. NewProviderBundle and PublishVersion reject a digest-pinned family package; HydrateProviderBundle accepts it. A rule that guards a write must not decide whether an already-persisted row can be read back: package rules change over time, a bundle is written once and read on every resolution that needs its set, and a row that was legal when it was written would otherwise drop out of every read at once. The write path could not repair it either, because an update reads before it writes. buildCloud splits the same rule the same way. The DECISION block in providerbundle.go records the trade-off.

Mutators

A ProviderBundle is a value object: each mutator returns a newProviderBundle value and bumps UpdatedAt unconditionally, even on a caller-side no-op like Rename(b.DisplayName(), now). The aggregate carries no "did the value actually change?" branching; the service layer refuses a patch that names no field at all.

MutatorChangesNotes
Rename(name, now)DisplayNameRe-validates non-empty + length. Authors no content, so LatestVersionNumber stays where it stands and every Cloud keeps reading the declaration it pins.
PublishVersion(packages, apiVersion, now)ProviderPackages, ProviderConfigAPIVersion, LatestVersionNumberDeclares packages under apiVersion as the bundle's next content version and increments LatestVersionNumber by one. The declaration is replaced whole: dropping a package means leaving it out, and a rejected set or apiVersion leaves the receiver untouched. Re-validates each pair, the set size, source uniqueness, the family-pin rule, and the apiVersion grammar.

There is one content mutator rather than the two the aggregate used to carry. A package set and the group it is served under describe one declaration: the packages of a bundle belong to one provider family, and that family serves one ProviderConfig group, so the two halves are only meaningful as a pair. Moving one without the other would mint a version whose packages have never been served under the group beside them: an intermediate declaration a Cloud could be pinned to and that nobody authored. A caller changing one half states the other as it already stands, and the service layer does exactly that for a patch naming only one of them, so a patch that names both still publishes one version.

There is no Reslug mutator and no ChangeProvider mutator, by deliberate design. The slug is the handle an operator types to address the bundle and the handle every declaration naming it carries, so re-slugging in place rots both. The provider is the compatibility key a reference to the bundle is admitted against, and nothing re-checks a reference once it is stored, so changing the provider in place would leave stored references admitted against a value that no longer holds, with no signal at all. Re-validating the referrers inside the mutator is the rejected alternative: it inverts the dependency (references point at the bundle, not the other way round) and puts a cross-aggregate scan inside a value-object mutator. "This bundle is now an azure bundle" means a different bundle to the operator, and creating it and re-pointing the affected references is the operation that says so.

Two counters

A ProviderBundle carries two numbers that both read as "the bundle's version", and they are deliberately named apart rather than folded into one.

LatestVersionNumber is the operator-facing content version. It counts the authored declarations, starts at 1, and only PublishVersion moves it, so a Cloud pinning version 3 keeps reading the packages version 3 declared however often the bundle is renamed. It is what the API, the CLI and the Cloud's pin speak, and it crosses the wire as latest_version.

Version is the internal compare-and-swap counter the repository's expected-version guard arbitrates lost updates with. It moves on every persisted mutation, a rename included, and it stays inside the repository's write path: it is never on the wire and never named by a pin. The Concurrent writes section below is what it exists for.

Reusing one counter for both duties was the rejected alternative. A rename would then mint a content version whose packages are byte-identical to its predecessor, and every Cloud wanting the current declaration would have to be re-pinned to a version that declares nothing new. The two duties also contradict each other: the guard needs a value that moves on every write, the pin needs one that moves only when the content does, and no single column can be both. Exposing the compare-and-swap counter alongside the content version was rejected too: the two would be indistinguishable to a client reading a bundle, and a client pinning the wrong one gets a Cloud that either never sees an edit or drifts on a rename. The DECISION block above the ProviderBundle type in providerbundle.go records the trade-off.

Publish, then promote

A bundle's content lives in plexsphere.provider_bundle_versions, one row per authored version keyed (bundle_id, version_number), with that version's packages in plexsphere.provider_bundle_version_packages. The bundle row itself keeps identity only. Publishing appends: the rows other Clouds are already pinned to are never rewritten, which is what lets a Cloud pinned to an older version keep resolving the same declaration.

The two halves of the change are therefore two writes on two aggregates:

  1. Publish. A content patch on the bundle appends the next version and moves the bundle's LatestVersionNumber. It writes no Cloud, so nothing a Cloud converges with changes, and nothing in any Cloud's own history records the publish.
  2. Promote. A write on one Cloud moves that Cloud's pin (PromoteProviderBundleVersion). It reaches that Cloud and no other.

Splitting them is what makes a rollout staged rather than fleet-wide. A subset of the referencing Clouds is promoted first; the rest keep serving the version they were on. Halting the rollout is not promoting further, and there is nothing queued to cancel. Rolling back is the same write pointed at an earlier version, because the pin moves in either direction.

Nothing progresses a pin on its own. No scheduler advances Clouds toward the latest version, and EffectiveProviderConfigResolver resolves the pin on every pass rather than the bundle's newest declaration, so a version published and never promoted is a version nothing installs.

Per-Cloud package overrides

A pinned version reads the same for every Cloud pinned to it. The one thing that may differ per Cloud is its override set: {source, version} pairs authored on the Cloud, held in plexsphere.cloud_provider_package_overrides and rendered source-ascending. The set is an overlay on the pinned declaration rather than a second declaration competing with it. ApplyProviderPackageOverrides is the merge: an override whose source the pinned version carries replaces that member's version, one naming a source the version does not carry joins the set, and nothing is ever removed. The ProviderConfig apiVersion stays outside the overlay — it is the pinned version's single Cloud-level value, because one ProviderConfig is rendered per Cloud.

The merge is written once and every reader shares it: EffectiveProviderConfigResolver applies it for the API and the console, and the fleet's desired-package adapter resolves through that same resolver, so the set an operator reads is the set that installs. The two source lists the merge returns beside the effective set carry the provenance — OverriddenSources names the pinned members whose version an override replaced, AdditionSources the sources it joined the set with — and the read surfaces stamp each effective package bundle, override, or addition from them. The classification is computed per read and persisted nowhere: it is a statement about two values written independently of each other, and a stored copy would be wrong from the next write to either.

A promotion is where that independence shows. It moves the pin and writes nothing about the overrides, so the deviations travel with the Cloud onto the new declaration. A shadow can therefore outlive the member it shadowed: the newly pinned version may not declare that source at all, and the next read reports the same override as an addition. Rolling the pin back makes it a shadow again, and clearing the set is what ends it. The re-classification is the only signal, because the promotion has nothing to refuse: an override is legal whether or not the pinned version carries its source.

Dropping the overrides whenever the pin moves was the rejected alternative. A rollout is one promotion per Cloud, so a promotion that cleared the set would delete operator-authored configuration on every Cloud it reached while reporting each one as promoted, and a rollout covering a whole roster would do it to every referencing Cloud at once. Clearing the set is its own write, stated on the field. What that costs is the stranded shadow above, which the origin on every read names.

Concurrent writes

A bundle update is a read, an in-memory merge, and a write, and the read and the write are separate transactions. Nothing in that sequence reads the row it overwrites, so no isolation level can notice that two patches touching different fields were computed from the same starting state — the second write would restore the first writer's old value for the field it did not touch and still report success.

UpdateProviderBundle therefore carries an optimistic-concurrency predicate: the write matches the row only while version still holds the value the merge started from, and the same statement bumps the counter so the next racer has a fresh value to lose against. The loser matches zero rows, the repository re-reads to tell a moved row from a deleted one, and the caller gets repo.ErrProviderBundleStale — retry against a fresh read — or repo.ErrProviderBundleNotFound. No outbox event and no audit row is written for a refused update.

The token is the counter, not updated_at. now() is transaction_timestamp(): a microsecond-resolution wall clock that is neither unique nor monotonic. Two transactions that begin inside the same microsecond stamp the same value, and a backwards clock step — an NTP correction, a snapshot restore, a host live-migration — makes the database hand out values it has already handed out. Either repetition lets a writer working from an overtaken read match a predicate that was meant to refuse it, revert the winner's committed change and report success. A counter the database increments has neither failure mode. The IdP binding aggregate made the same call, for the same reason.

Because the counter is what the caller must hand back, ProviderBundleRepo.Create returns the aggregate hydrated from the row it just wrote rather than the one it was given: created_at, updated_at and version are assigned by the database, so the value a caller passed in describes no row and would be refused as stale on the very next write.

A bundle Clouds reference is not deleted. ProviderBundleRepo.Delete counts the Clouds whose provider_bundle_id names the bundle inside the delete transaction and refuses while that count is non-zero: the caller matches repo.ErrProviderBundleReferenced and uses errors.As to extract the *ProviderBundleReferencedError carrying the number of referencing Clouds, and ProviderBundleService.Delete hands the error back unchanged. The row stays; no outbox event and no audit row is written for a refused delete. Removing the bundle under its referencing Clouds would leave each of them with no provider configuration at all: no bundle to resolve one from, and neither the inline packages nor the apiVersion clouds_provider_mode_check requires of a Cloud that references none.

The count reports, the schema enforces. A Cloud that starts referencing the bundle between the count and the DELETE trips the composite foreign key clouds_provider_bundle_provider_fkey and its ON DELETE RESTRICT, and the SQLSTATE 23503 folds onto the same repo.ErrProviderBundleReferenced sentinel, without a count: the failed statement has already aborted the transaction, so no follow-up statement can recount. CloudRepo.Delete records the same limitation for its own racing-INSERT arm.

An accepted delete carries no version expectation: the DELETE returns the row it removed, so the slug and provider the ProviderBundleDeleted payload denormalises come from the delete itself. Exactly one caller removes a row and emits the event; a concurrent second caller matches nothing and reads the not-found sentinel rather than being told a delete succeeded that it did not perform.

Outbox events

The Cloud Inventory services emit six typed domain events to plexsphere.outbox_events inside the same Postgres transaction as the aggregate-row mutation, three per aggregate. The EventType literal is written to the event_type column verbatim — the string form is part of the wire contract once a row has been emitted.

Event type (column value)TriggerPayload struct
cloudprov.CloudCreatedA new Cloud is created. The payload denormalises slug / provider / external_id and carries created_by — the ReBAC subject of the creating principal, from which the authz/sync layer seeds the cloud:<id>#cloud_admin grant.events.CloudCreated
cloudprov.CloudUpdatedA Cloud's mutable attributes change. fields_changed carries NAMES only — no before/after values — to honour the audit pseudonym contract.events.CloudUpdated
cloudprov.CloudDeletedA Cloud is deleted. The payload denormalises slug / provider / external_id so downstream consumers can purge per-Cloud caches without joining back to a row that is already gone.events.CloudDeleted
cloudprov.ProviderBundleCreatedA new ProviderBundle is created. The payload names the aggregate by bundle_id, denormalises slug / provider, and carries created_by, the ReBAC subject of the creating principal. The ReBAC schema declares no provider_bundle definition, so no authz/sync arm consumes created_by today; it is on the wire from the first emission because an event already written to the outbox cannot grow a field later.events.ProviderBundleCreated
cloudprov.ProviderBundleUpdatedA ProviderBundle's mutable attributes change. Alongside bundle_id, fields_changed carries NAMES only (no before/after values), the same audit contract cloudprov.CloudUpdated honours. published_version is the content version the mutation authored, and zero when it authored none: a rename moves no declaration, so nothing downstream needs to re-read the packages, while a content change mints a version and this is the number a consumer promotes a Cloud to. It stays inside the names-only contract because it names which version row was written, not what it contains.events.ProviderBundleUpdated
cloudprov.ProviderBundleDeletedA ProviderBundle is deleted. Alongside bundle_id, the payload denormalises slug / provider so downstream consumers can purge per-bundle caches without joining back to a row that is already gone.events.ProviderBundleDeleted

Every payload carries a UUIDv7 event_id and a UTC occurred_at timestamp. The event_type set is closed: adding a value to it is a breaking wire-contract change, not a switch-statement extension.

Empty-aggregate-on-delete contract

CloudRepo.Delete refuses to delete a Cloud that is still referenced by a Cloud Credential. Two child kinds block a delete, each backed by a to_regclass-guarded counter helper folded behind CountChildrenByCloud:

  • a CloudCredential whose home is this Cloud (plexsphere.count_cloud_credentials_for), and
  • a Cloud Credential usage edge that names this Cloud — a credential homed on another Cloud but attached to this one through the many-to-many usage join (plexsphere.count_cloud_credential_usages_for; see credential-pool.md). Without counting these edges, a Cloud blocked only by a usage edge would trip the join's ON DELETE RESTRICT cloud FK as a raw 23503 race instead of a clean conflict.

The schema declares both cloud_id foreign keys with ON DELETE RESTRICT. A non-zero count in either kind short-circuits the delete with repo.ErrCloudNotEmpty; callers use errors.As to extract the structured *CloudNotEmptyError carrying the per-kind CloudChildCounts, and the transport layer renders a 409 cloud_not_empty Problem. The human-readable Problem detail reports both counts; the structured CloudChildCounts extension currently carries the home-credential count only. A concurrent INSERT that races the count is caught by defence-in-depth — the SQLSTATE 23503 foreign-key violation surfaces as the same 409.

Unhydratable rows on the list path

Since the provider packages moved into plexsphere.cloud_provider_packages, no foreign key can require a Cloud to have at least one package row — a foreign key constrains a child to its parent, never a parent to at-least-one child. The "an inline Cloud declares at least one package" invariant is therefore the aggregate's alone, and a writer that bypasses the aggregate (a partial restore, logical replication, a data-repair script) can leave a Cloud with none.

A row in bundle mode is corrupt the other way round, when it still carries package rows of its own: the provider configuration lives on the referenced bundle, so those rows are a declaration nothing reads, and the aggregate refuses to hold a reference and inline packages at once. clouds_provider_mode_check does not catch that shape either — a CHECK constraint reads only the row it is attached to, and the package rows sit in another table — so this shape is the aggregate's alone in the same way as the first.

CloudRepo.Get fails loudly on either row: a single-Cloud read has nothing else to return. CloudRepo.List skips the row instead, because failing the page would take GET /v1/clouds down for every caller with no way to page past the row and no repair path through the API. The pagination cursor is derived from the last row read, not the last row hydrated, so a skip cannot make a page jump over its neighbours.

That trade-off makes the response itself silent: a 200 carrying a page that omits a Cloud looks exactly like a complete one. Two signals cover the gap, and an operator needs both:

  • plexsphere_provisioning_cloud_rows_skipped_total — an unlabelled counter the persistence adapter advances once per skipped row (cloud/repo/metrics.go). Any advance means at least one Cloud is missing from the pages being served, and this is the only series that moves — alert on it.
  • A WARN line naming the cloud_id and the rejection reason, which is what identifies the row to repair. The identity stays out of the metric label deliberately: one corrupt Cloud must not mint a permanent time series.

The counter is registered by the /v1/clouds composition root, which is the only site that reaches List; the resolver sites in other surfaces call Get only and emit neither signal.

Rolling back past the override table

A down-walk reaches 0082_cloud_provider_package_overrides.sql first, and its Down declines with SQLSTATE 0A000 while any Cloud still carries an override. The database has a defined answer below that version — the pinned declaration as it stands — but the management clusters do not, and the difference cannot be reconciled afterwards. The fleet never uninstalls a Provider it stops desiring: it drops the install record and leaves the object running. So dropping the rows would leave every added package's controller on its clusters with nothing in the database naming it, and would hand every shadowed package to the next sweep to be rewritten back to the pinned version — an active downgrade of a running controller reported to the operator as a successful rollback.

Clear the sets through the API first, with PATCH /v1/clouds/{id} and "provider_package_overrides": [] per overriding Cloud, then run the down-walk. That does not remove an added package's Provider object either — nothing does, by design — but it converges every shadowed package back onto the pinned version and leaves the change in the audit trail and the CloudUpdated stream, so what survives on the clusters is the consequence of an operator action rather than an unrecorded artefact of a migration. The Clouds still holding one are:

sql
SELECT c.slug, o.package_source, o.package_version
  FROM plexsphere.cloud_provider_package_overrides AS o
  JOIN plexsphere.clouds AS c ON c.id = o.cloud_id
 ORDER BY c.slug, o.package_source;

The rollback archive

Rolling the schema back past the migration that added provider_bundle_id (0080_clouds_provider_bundle_ref.sql) removes every bundle-mode Cloud. Below that version a Cloud must carry a non-empty provider_config_api_version of its own, and a Cloud that references a bundle has none. Rolling forward again does not bring the rows back, so the Down copies each doomed row into plexsphere_rollback.clouds_0080_removed before it deletes. That table is the only record of the removal that survives the rollback.

The archive lives in its own schema on purpose: 0001_init.sql's Down ends with a plain DROP SCHEMA IF EXISTS plexsphere, which would fail on a surviving archive table inside that schema and strand a full down-walk at version 1. The same placement is what makes the archive nobody's by default — no migration drops plexsphere_rollback, no application code reads it, and each Down / Up / Down cycle appends another generation of rows, told apart by removed_at.

Two operator duties follow.

Reconstructing a removed Cloud reads the archive back and re-states the declaration inline. The bundles themselves survive the rollback, so the apiVersion and the package pins the Cloud used to resolve through are still readable next to it:

sql
SELECT c.slug, c.display_name, c.provider, c.external_id,
       c.endpoint, c.region_defaults, c.removed_at,
       b.slug AS provider_bundle_slug,
       b.provider_config_api_version
  FROM plexsphere_rollback.clouds_0080_removed AS c
  JOIN plexsphere.provider_bundles AS b ON b.id = c.provider_bundle_id
 ORDER BY c.removed_at DESC, c.slug;

Decommissioning an installation drops the archive explicitly. Walking the migration stack to zero does not: it drops plexsphere and leaves plexsphere_rollback holding every removed Cloud's display_name, slug, external_id — the cloud-provider account identifier — and its endpoint and region-default JSON, in a schema nothing else names. Make this the last step of the teardown, after the down-walk:

sql
DROP SCHEMA IF EXISTS plexsphere_rollback CASCADE;

Application service and HTTP surface

The application-service layer (cloud/services/) composes the CloudRepo, the per-provider Validator, an AuditSink, and a ProviderBundleReader to orchestrate five operations — Create, Get, List, Update, Delete. The service runs the per-provider validator before aggregate construction on Create and before persistence on Update, translates repo sentinels into service sentinels, and emits a NAMES-only audit row on every successful mutation. The service does not do authorization (the transport layer runs ReBAC first) or HTTP wire-shape concerns.

CreateInput.ProviderBundleID and UpdateInput.ProviderBundleID name the ProviderBundle a Cloud takes its provider configuration from, and they are exclusive with the inline ProviderPackages / ProviderConfigAPIVersion pair the way the aggregate's two modes are. A payload that names both is refused with ErrCloudPayloadInvalid listing every field that took part. A named bundle is admitted before the write: the id must parse, the row must exist (repo.ErrProviderBundleNotFound propagates unchanged), and the bundle's provider must equal the Cloud's, or the call fails with ErrProviderBundleProviderMismatch. Nothing re-checks a stored reference, which is why the check runs at admission.

ProviderBundleVersion rides alongside on both inputs and states which published version the Cloud pins. Left unset on an attach, the write pins the bundle's LatestVersionNumber, the declaration an operator reads when they pick the bundle. Set on its own in an update, on a Cloud already in bundle mode, it is the promotion, and fields_changed names the pin alone: a consumer branching on provider_bundle_id would otherwise re-read every Cloud on a bundle whenever one of them moved. Set on a Cloud that neither is nor becomes a bundle reference, it is refused, because there is no bundle for the version to point into. A version the bundle never published is caught before the write commits, by the same resolution the write path already runs, and reported as repo.ErrProviderBundleVersionNotFound, which is distinct from a missing bundle, so the operator picks another version rather than another bundle.

ExpectedProviderBundle is the compare-and-swap precondition every write that names no bundle but depends on one carries. A promotion does: a caller that has to resolve the Cloud's reference before the call — the HTTP handler does, to authorize against the bundle — decided something on a read Update does not share. An override-set write does too, for a different reason: the versions the set names were chosen against the declaration the Cloud pinned at that read, so the write states that reference even though it authorizes against no bundle. Restating it makes the write assert the reference rather than trust it, and a row that disagrees is refused with ErrProviderBundleReferenceMoved.

The expectation is the reference as the pair the Cloud holds it as — ProviderBundleRef, the bundle and the version pinned on it. Both writes rewrite both columns out of the aggregate the write path loaded, so an expectation on the bundle alone leaves the pin unasserted: the override write, which moves neither column, would revert a promotion that committed inside the window while reporting success, and the fleet sweep would then apply the older declaration to every cluster on the Cloud.

The expectation is asserted twice, against the two reads that can disagree with it: the service checks it against the row it loads, which refuses the common case before the validator and the bundle resolution run, and the repository re-asserts it in the WHERE clause of the UPDATE itself, which is what covers the window between that load and the write — a window the resolver's live bundle query sits inside of. Only the second check can, because only it runs in the writing statement. The zero ProviderBundleRef is a meaningful expectation ("the Cloud referenced no bundle"), so the precondition is carried by the pointer being set, not by the value being non-zero. An attach needs none: it writes the id the caller stated, so there is no second read to disagree with the first.

The expectation the HTTP handler supplies is the reference IT read while serving the request, not one the client stated, so the window it covers runs from that read to the write. A reference that moved between the client's own GET and the arrival of its PATCH is not covered: the patch applies to the Cloud as the handler finds it. Closing that wider window needs a client-supplied validator — a restated reference the write asserts, or an If-Match ETag on the Cloud representation — which the wire contract does not carry today. For the same reason the override set itself is replaced wholesale under no precondition, exactly as provider_packages is: two override patches racing each other are last-writer-wins on the set.

Create, Get, List and Update return a CloudRead / CloudReadPage: the aggregate plus the EffectiveProviderConfig it converges with, resolved through EffectiveProviderConfigResolver. A consumer reads the package set and the ProviderConfig apiVersion off Effective and everything else off the aggregate, so a referencing Cloud and an inline one render through the same code. Update is in that set because it answers for a stored Cloud whose mode was decided elsewhere: a patch of display_name on a referencing Cloud leaves it referencing, and projecting the aggregate would report it as installing nothing one moment before a GET on the same id reports the bundle's packages. Create is in it for the same reason read the other way round: the Cloud it builds may name a bundle, and then it carries no declaration of its own either.

What the resolver answers with is the pinned version's declaration, not the bundle's newest one. It resolves on every pass and keeps nothing past the pass it was computed for, so publishing a version moves nothing anywhere: the next read, the next converge and the next installer run all re-read the same pinned declaration. A Cloud moves when its pin moves, which is a write to that Cloud and reaches no other. Two Clouds on one bundle pinned to different versions are two independent answers.

Create resolves after its insert has committed and its audit row is emitted. An inline Cloud resolves from its own values and reaches no bundle read at all; a referencing one costs a single batched read carrying its one pin. When that read fails, the call answers through the error path (the repo sentinels stay matchable through the wrap) while the row stays committed, so the Cloud exists and a follow-up GET /v1/clouds/{id} serves it.

A page resolves its declarations in one batched read (ProviderBundleReader.ResolveVersions), not one read per Cloud: a per-Cloud loop would let any authenticated caller turn a two-query GET /v1/clouds?limit=200 into a couple of hundred read transactions on the shared interactive pool, all of them paid before the transport's per-row visibility filter has dropped a single item. The batch is keyed by the pin rather than by the bundle, so two Clouds on one bundle at different versions ask for two declarations and each gets its own. Nothing is cached beyond the call, so a promotion between two pages is visible on the second.

A Cloud whose pinned declaration the read port cannot produce — corrupt beyond hydration, or deleted in a read-then-delete race — is skipped by List, for exactly the reason CloudRepo.List skips an unhydratable Cloud row one layer down: failing the page would take GET /v1/clouds down for every caller, including callers whose visible Clouds are all inline. Get keeps failing loudly, because the caller addressed that single Cloud. A failure of the batched read itself still fails the call — an unreachable database is not the same fact as "no version exists", and treating it as one would blank every referencing Cloud's configuration at once.

The skip leaves one WARN breadcrumb per call, carrying the number of Clouds dropped and the first (cloud_id, provider_bundle_id) pair, not one line per dropped Cloud. A single broken bundle can be referenced by every Cloud on a page whose size the caller chooses, and the breadcrumb is written before the per-row visibility filter runs, so a line per Cloud would let any authenticated caller amplify one request into an unbounded number of log lines — during the very incident that produced the broken bundle.

The /v1/clouds HTTP surface is implemented under internal/transport/http/v1/clouds/: CreateCloud is gated on platform#manage, GetCloud / ListClouds on the per-Cloud observe relation, and PatchCloud / DeleteCloud on cloud#manage. The per-operation request/response schemas, the ReBAC gate, and the closed Problem.code taxonomy are documented in the clouds API reference.

The same package holds a ProviderBundleService over the matching operations for the ProviderBundle aggregate. Create, Update and Delete emit an audit row alongside the outbox event they append; Get, List, the referencing-Clouds roster and the published-version history return their result unchanged and emit nothing, because a read leaves no domain-event trail of its own. The Update and Delete rows carry the per-object provider_bundle:<id>, the object the ReBAC definition declares for the aggregate. Create carries platform:plexsphere: the bundle id does not exist until the INSERT commits, so the platform singleton is the only object the create decision can name.

The /v1/provider-bundles HTTP surface is implemented under internal/transport/http/v1/providerbundles/: CreateProviderBundle is gated on platform#manage, GetProviderBundle / ListProviderBundles / ListProviderBundleVersions on the per-bundle observe relation, and PatchProviderBundle / DeleteProviderBundle / ListProviderBundleClouds on provider_bundle#manage. The version history sits on the read permission rather than the mutation one because a version row is the bundle's own content (the packages and apiVersion GetProviderBundle already serves under observe, frozen under an older number), so it names no Cloud and discloses nothing that read does not. That layer also writes the read path's audit row, relation provider_bundle.read, next to the ReBAC decision, so the trail pairs the decision with the bytes served. The roster of Clouds referencing a bundle sits on the mutation permission rather than the read one because its rows name Clouds — id, slug, display name — in whatever Domain holds them, while observe on a bundle derives from the platform singleton and grants no cloud#observe anywhere. Under that gate the roster is served whole rather than filtered per Cloud: it answers a question about the bundle, and a filtered page would under-report how far a promotion of it can reach. The per-operation request/response schemas, the ReBAC gate, and the closed Problem.code taxonomy are documented in the provider bundles API reference.