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 on the new Cloud.
  • ../../reference/api/clouds.md — the per-operation /v1/clouds HTTP reference: request/response schemas, ReBAC gate, and the closed Problem.code taxonomy.
  • ../../../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 — one aggregate, three value objects, a validator family, three 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, created_at, updated_at). 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 at the current milestone is {aws, azure}. 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
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; aws and azure register themselves 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}.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.
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.

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 at the current milestone is exactly two values:

ProviderString form
AWSaws
Azureazure

ParseProvider validates an input string against the taxonomy and is case-sensitive: only lowercase aws / azure 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 third 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.

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; aws and azure register themselves at init() time from their own files 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.

Outbox events

The Cloud Inventory service emits three typed domain events to plexsphere.outbox_events inside the same Postgres transaction as the aggregate-row mutation. 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

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

Empty-aggregate-on-delete contract

CloudRepo.Delete refuses to delete a Cloud that still has at least one CloudCredential referencing it. The schema declares the credential's cloud_id foreign key with ON DELETE RESTRICT, and the plexsphere.count_cloud_credentials_for(cloud_id) helper folds the count behind a to_regclass guard so the query works even before the cloud_credential table exists. A non-zero count 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 with the counts payload. A concurrent INSERT that races the count is caught by defence-in-depth — the SQLSTATE 23503 foreign-key violation surfaces as the same 409.

Application service and HTTP surface

The application-service layer (cloud/services/) composes the CloudRepo, the per-provider Validator, and an AuditSink 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.

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.