# cloud-forge architecture ## The non-negotiable rule > Do not create one crate per AWS service. Build the primitives once, > then compose services from those primitives. `cloud-forge` is a from-first-principles attempt at the substrate underneath an AWS-shaped cloud platform: resource identity, lifecycle, ownership, policy, tagging, events, and quota — the handful of concepts that every cloud service (compute, storage, database, messaging, …) reuses rather than reinvents. No crate in this workspace is named after an AWS product, and none will be until it is a thin composition of already-real primitive crates, not a container for logic that belongs in one of them. This mandate carries the same weight as the root workspace's "100 crates, none of them fake" rule (see the repository root [`docs/CRATE_ARCHITECTURE.md`](../../docs/CRATE_ARCHITECTURE.md)) and `verification-forge`'s hard invariants: a crate exists here only when it owns a real, independently-testable invariant. ## Why a third workspace `tlm-jxcl-forge` already separates two independent Cargo workspaces: the root workspace (the TLM JXCL ISA, post-quantum crypto/storage, one reference service) and `verification-forge` (a from-scratch proof kernel). Neither is a natural home for cloud-resource modeling: - The root workspace's `docs/crates.toml` registry is machine-checked at exactly 100 crates with its own dependency-DAG rules (`tools/check_workspace.py`); folding an open-ended, many-phase cloud platform into it would either blow past that validated count or force cloud crates to misrepresent themselves as ISA/crypto crates. - `verification-forge` is a general-purpose proof kernel with no cloud-specific concept in it at all — it will eventually *verify* invariants about `cloud-forge` (see "Relationship to verification-forge" below), which means it must stay independent of it, not host it. So `cloud-forge/` is a third, independent top-level Cargo workspace, with its own `Cargo.toml`, `crates/`, and quality gates — exactly the same shape as `verification-forge/`. ## Layering ```mermaid flowchart TB subgraph existing["Already real, in the root workspace"] isa["jxcl* — ISA, execution engine"] pq["pq* — PQ crypto, storage, ZK proofs"] end subgraph kernel["cloud-forge Phase 1: the primitive kernel (this pass)"] types["cloud-types
ResourceId, Arn, AccountId, RegionId"] errors["cloud-errors
shared CloudError"] tags["cloud-tags"] lifecycle["cloud-lifecycle
state machine"] resource["cloud-resource
Resource<T> wrapper"] region["cloud-region"] account["cloud-account"] identity["cloud-identity
Principal (minimal)"] policy["cloud-policy
Allow/Deny, deny dominates"] events["cloud-events"] quota["cloud-quota"] core["cloud-core
facade"] types --> errors errors --> tags errors --> lifecycle types --> resource tags --> resource lifecycle --> resource types --> region types --> account types --> identity identity --> policy types --> policy errors --> policy types --> events types --> quota resource --> core region --> core account --> core identity --> core policy --> core events --> core quota --> core end subgraph future["Planned, later phases — not built yet"] control["Phase 2: control plane
(provisioning, reconciliation, scheduler)"] services["Phase 4+: service layer
(compute, storage, database, …)"] end existing -.underlies.-> kernel kernel --> future ``` Every arrow above is a real `[dependencies]` edge in a crate's own `Cargo.toml` — there is no crate in this phase that depends on something not yet built, and no crate that exists only to be depended on later without doing anything itself today. ## The resource model (Phase 0 / Phase 1) Every cloud resource, regardless of which future service creates it, carries the same fields — this is the one model every service composes from rather than redefining: | Field | Type | Owning crate | |---|---|---| | `id` | `ResourceId` | `cloud-types` | | `resource_type` | `ResourceType` | `cloud-types` | | `region` | `RegionId` | `cloud-types` / `cloud-region` | | `account` | `AccountId` | `cloud-types` / `cloud-account` | | `owner` | `AccountId` | `cloud-types` | | `lifecycle` | `Lifecycle` | `cloud-lifecycle` | | `version` | `u64` | `cloud-resource` | | `tags` | `Tags` | `cloud-tags` | | `created_at` / `updated_at` | `u64` (unix millis) | `cloud-resource` | `owner` here means *account ownership* (who a resource bills to and belongs to) — distinct from `cloud-identity::Principal`, which `cloud-policy` evaluates access for. A resource-based policy attaching a `Principal` to a specific resource is a Phase 13 (IAM) concept, not pulled forward into this phase's `Resource`. `cloud-resource::Resource` combines all of these plus a service-specific payload `T`, so a future `Instance` or `Bucket` type is `Resource` / `Resource`, not a hand-rolled struct that has to remember to include `tags` and `lifecycle` correctly on its own. The internal ARN format `cloud-types::Arn` implements is exactly the one specified in the roadmap: ``` jxcl:cloud::::: ``` ## What Phase 1 deliberately does not include - **`cloud-metadata` is not a separate crate.** Its proposed fields (`created_at`/`updated_at`/tags/version) are exactly `cloud-resource` and `cloud-tags`'s fields; a separate crate would own nothing a caller couldn't already get from those two. - **`cloud-runtime` is deferred past Phase 2 too, now to Phase 4 (compute primitives).** Phase 2 does add a scheduler, but what it schedules is *placement* (which `AzId` a resource is assigned to) — a decision `cloud-scheduler` owns directly using `cloud-types`'s existing `AzId`, with no new wrapper type needed. A "runtime" only becomes a real, distinct primitive once something is actually executing (a VM, a container, a function invocation) — Phase 4's concern, not Phase 2's. - **`cloud-identity` is intentionally minimal in this phase**: a `Principal` enum (`User`/`Role`/`Service`, each carrying an id) — enough for `cloud-policy` to evaluate against. The full IAM surface (credentials, sessions, federation, policy documents-as-JSON) is Phase 13 in the roadmap and is not pulled forward. - **No AWS-named crate exists anywhere in this phase.** `services/ec2`, `services/s3`, etc. cannot be started honestly until the primitives they'd compose (compute, storage, network primitives — later phases) are themselves real. ## Phase 2: the control plane Phase 2 is where Phase 1's primitives stop being independent data types and start composing into an actual pipeline: a request to create a resource gets authorized, checked against quota, placed somewhere, constructed, and recorded — with the whole operation succeeding atomically or rolling back cleanly. This is also where the roadmap's deterministic state machine ``` REQUEST -> AUTHENTICATE -> AUTHORIZE -> VALIDATE -> PLAN -> APPLY -> VERIFY -> COMMIT -> AUDIT ``` becomes a real, tested code path rather than a diagram. (`AUTHENTICATE` is not separately modeled here: this phase takes an already-resolved `Principal` as input, since credential/session verification is Phase 13's IAM surface, not Phase 2's control-plane concern.) ### What's real in Phase 2 | Crate | Owns | |---|---| | `cloud-scheduler` | Placement: picks the least-loaded `AzId` in a region, from `cloud-region`'s registry and a caller-supplied usage count per AZ | | `cloud-service-registry` | A registry of named control-plane services and their health (`Healthy`/`Unhealthy`/`Unknown`) | | `cloud-reconciler` | Given a current and a desired `Lifecycle`, computes the shortest valid transition path through `cloud-lifecycle`'s state graph (via BFS), or reports the desired state as unreachable | | `cloud-provisioner` | The `AUTHORIZE -> VALIDATE -> PLAN -> APPLY -> VERIFY -> COMMIT -> AUDIT` pipeline itself, composing `cloud-policy` + `cloud-quota` + `cloud-scheduler` + `cloud-resource` + `cloud-events`, with each stage's failure distinguishable and every reservation made by an earlier stage rolled back if a later stage fails | | `cloud-control-plane` | `ControlPlane`: holds the account/region registries, policy, quota tracker, event log, and a resource store; exposes `create` (via `cloud-provisioner`), `get`, `list`, `delete` | ### What Phase 2 deliberately skips or defers - **`resource-manager`, `lifecycle-manager`, `quota-manager`, `policy-engine`, `account-manager`, `region-manager` are not separate crates.** Each would be a thin wrapper doing nothing but forwarding to `cloud-resource`, `cloud-lifecycle`, `cloud-quota`, `cloud-policy`, `cloud-account`, or `cloud-region` — exactly the one-crate-per-name anti-pattern this workspace's non-negotiable rule exists to prevent. `cloud-control-plane` holds these Phase 1 primitives directly instead of through an extra indirection layer that owns nothing new. - **`control-api` (a REST/gRPC layer) is deferred.** The roadmap's own execution order puts the API layer (Phase 30) well after the control plane it exposes; building an HTTP surface before the control plane it would serve is stable is building a service, not a primitive. - **`cloud-runtime` is deferred to Phase 4** — see above. ### The rollback invariant `cloud-provisioner`'s pipeline makes a real claim worth stating explicitly: if `AUTHORIZE` succeeds but a later stage (`VALIDATE`, `PLAN`, `APPLY`, or `COMMIT`) fails, every resource reservation an earlier stage made (currently: `cloud-quota` usage) is released before the pipeline returns its error. This is tested directly — not inferred from "the individual stages are each atomic" — since a multi-stage pipeline built from atomic parts is not itself atomic unless someone wires the rollback path and verifies it. ## Phase 3: AWS-like resource identification The roadmap names four crates for this phase: `cloud-arn`, `cloud-resource-id`, `cloud-resource-parser`, `cloud-resource-registry`. Three of the four already exist — just earlier than the roadmap's own ordering expected, because the resource model itself needed them from day one: | Roadmap name | Where it actually lives | Since | |---|---|---| | `cloud-arn` | `cloud_types::Arn` — parses and formats `jxcl:cloud:::::` | Phase 1 | | `cloud-resource-id` | `cloud_types::ResourceId` | Phase 1 | | `cloud-resource-parser` | `Arn`'s own `FromStr` impl — a separate parser crate would parse into the exact same `Arn` struct `cloud-types` already owns, with nothing new to own | Phase 1 (not a separate crate — see below) | | `cloud-resource-registry` | **New this phase** | Phase 3 | `Resource` (Phase 1) has an id, a type, a region, and an owning account, but nothing before Phase 3 ever assembles those into the *canonical, fully-qualified name* for a provisioned resource, or lets anyone look a resource up by that name rather than by its bare `ResourceId`. That's what this phase adds: - **`cloud-resource-registry`** owns exactly one thing: an `Arn` ↔ `ResourceId` mapping. It rejects a second registration of an already-used ARN, and (a stricter, deliberate choice beyond what the roadmap specifies) rejects registering the same resource under a second ARN too — a provisioned resource's canonical name is chosen once, at creation, and does not change. - **`cloud-control-plane`** now knows its own `partition` and `service` (constructor arguments), and computes each resource's `Arn` from `partition` + `service` + the resource's own region/account/id at `create()` time, registering it in an internally-owned `ResourceRegistry` — with the same rollback discipline as every other `create()` failure mode: if ARN construction or registration fails, the quota reservation and placement `create()` already made through `cloud-provisioner` are released before the error returns, exactly like an `APPLY` failure. `resolve_arn()` and `arn_of()` expose the new lookup direction. `cloud-provisioner` itself is untouched by this phase: ARN identity is a *naming* concern that belongs to whatever owns a `partition`/ `service` (the control plane), not to the provisioning pipeline, which stays deliberately ignorant of naming schemes. ## Phase 4: compute primitives Phase 1 deferred `cloud-runtime` twice (first past Phase 1, then again past Phase 2) on the same stated grounds: "a runtime only becomes a real, distinct primitive once something is actually executing." This phase is where that becomes true, and where the two other primitives a compute-shaped resource needs alongside a runtime -- a supply of capacity to run on, and something to boot from -- are built: | Crate | Owns | |---|---| | [`cloud-runtime`](./crates/cloud-runtime) | `RuntimeState`: the execution-state machine (`Pending`/`Running`/`Stopping`/`Stopped`/`Terminating`/`Terminated`) for something actually running, kept deliberately separate from `cloud-lifecycle::Lifecycle` (see below) | | [`cloud-capacity`](./crates/cloud-capacity) | Per-AZ vCPU/memory-MiB capacity: reservations fail closed against an AZ's registered total | | [`cloud-image`](./crates/cloud-image) | A registry of machine images to launch from: validated id, name, size, architecture; immutable once registered | ### `cloud-runtime` vs. `cloud-lifecycle` These are not the same state machine wearing two names. `Lifecycle` answers "does this resource *record* exist, and in what stage of being provisioned/updated/torn down?" -- a question every resource in this workspace has needed an answer to since Phase 1, whether or not anything about it ever executes. `RuntimeState` answers "is the thing that record represents currently running?" -- a question that is meaningless for a bucket or a policy, and that varies *independently* of `Lifecycle` for the resources it does apply to: a compute instance can sit `Lifecycle::Active` for its entire existence while cycling between `RuntimeState::Running` and `RuntimeState::Stopped` many times over. Collapsing the two into one enum would either force every non-compute resource to carry meaningless `Running`/`Stopped` states, or force compute resources to smuggle their execution state through `Lifecycle::Updating` -- both of which misrepresent what actually changed. Two small, single-purpose state machines is the primitive answer; one overloaded one is the shortcut this workspace's rule exists to reject. ### Why capacity is a different shape from quota `cloud-quota` (Phase 1) and `cloud-capacity` (this phase) look similar -- both track a limit and a running usage -- but encode an opposite default on purpose. `cloud-quota` is an *account cap*: a resource category nobody has configured a limit for is unlimited, because the absence of a quota policy should not itself block anything. `cloud-capacity` is a *physical(-ish) pool*: an availability zone nobody has told its own size has nothing to schedule onto, so `CapacityTracker::try_reserve` fails closed with `NotFound` against an unregistered AZ rather than inventing an unlimited pool that doesn't exist. Same shape of code, opposite failure mode, because they model opposite kinds of limit. ### The one real composition this phase adds `cloud-scheduler` (Phase 2) gains `place_least_loaded_with_capacity`, which filters `cloud-region`'s candidate AZs down to the ones `cloud-capacity` reports enough spare room in, picks the least-loaded survivor exactly as `place_least_loaded` already did, and reserves that capacity on the winner in the same call -- all-or-nothing, per `cloud-capacity::CapacityTracker::try_reserve`'s own atomicity, and touching neither `usage` nor `capacity` for any AZ if none qualifies. This is a deliberately generic extension (any resource type can ask for capacity-aware placement, not just compute), which is why it lives in `cloud-scheduler` rather than in a new crate: capacity-aware placement is a placement-policy concern `cloud-scheduler` already owns, not a compute-specific one. ### What Phase 4 deliberately does not include - **No `cloud-compute` (or similarly named) crate exists yet.** Wiring `cloud-runtime` + `cloud-capacity` + `cloud-image` into an actual "launch an instance" operation -- a compute-specific `ProvisionRequest` payload, start/stop/terminate operations driving `RuntimeState` transitions -- is the first genuinely compute-shaped service this workspace would build, and per the non-negotiable rule, that composition doesn't get built (or named) until it is one: three standalone, independently real primitives today, not a shell crate waiting for them. - **`cloud-provisioner` and `cloud-control-plane` are untouched by this phase.** Reserving vCPU/memory at provisioning time is a compute-specific requirement, not a property every resource `cloud-provisioner`'s generic pipeline creates has (a bucket or a policy has no vCPU count) -- baking it into the generic pipeline would be exactly the kind of one-off special case this workspace's primitives are supposed to prevent. The capacity-aware scheduler function above is available to whatever later composes a compute service on top of the existing pipeline. - **Image deregistration is not implemented.** Whether an image that's still referenced by a launched (but not yet modeled) instance can be safely removed is a question `cloud-image` alone cannot answer -- it would need to see resource-to-image references that don't exist in this workspace until compute resources themselves do. ## Phase 5: storage primitives Compute (Phase 4) needed something to execute, a pool to run it in, and something to boot it from. Storage needs a different set of concepts entirely -- nothing about vCPUs or images applies -- but the same discipline: build what every storage-shaped resource (an object, a volume, a database's write-ahead log) would otherwise redefine for itself, and nothing that presupposes a specific service shape: | Crate | Owns | |---|---| | [`cloud-checksum`](./crates/cloud-checksum) | `Checksum`: a from-scratch CRC-32/ISO-HDLC content-integrity checksum, streaming or one-shot | | [`cloud-redundancy`](./crates/cloud-redundancy) | `RedundancyScheme`: replication or erasure coding, shard counts, reconstruction threshold, max tolerable loss | | [`cloud-attachment`](./crates/cloud-attachment) | `AttachmentState`: the attach/detach state machine for a volume-like resource | ### Why `cloud-checksum` has zero dependencies and isn't a wrapper around a crates.io hash crate Every other crate in this workspace either has zero dependencies or depends only on other `cloud-forge` crates -- there has never been an external (crates.io) dependency anywhere in this workspace, and this phase doesn't start now. CRC-32/ISO-HDLC is implemented directly, bit-by-bit rather than via a precomputed table, favoring an obviously-correct implementation over a fast one. Its correctness is anchored to the algorithm's own published standard check value (`crc32(b"123456789") == 0xCBF4_3926`, the same value every real implementation -- zlib, gzip, PNG -- uses to confirm itself), not just internal self-consistency between `of()` and a hand-rolled alternative. This is a deliberate application of this workspace's anti-hallucination discipline to code, not just prose: a checksum algorithm invented for this crate, with no external value to check it against, would be exactly the kind of unverifiable claim this project avoids elsewhere. ### Why redundancy and attachment are their own primitives `RedundancyScheme` models an arithmetic property (how many of N shards must survive to reconstruct the original), not an encoder -- this crate never touches actual bytes, which is why it composes with any future storage service regardless of whether that service replicates whole objects or erasure-codes them. `AttachmentState` is a third state machine alongside `cloud-lifecycle::Lifecycle` and `cloud-runtime::RuntimeState` (Phase 4), and is worth contrasting with both explicitly: `Lifecycle` tracks whether a resource *record* exists, `RuntimeState` tracks whether a *compute* resource is executing (meaningless for a volume), and `AttachmentState` tracks whether *this* volume is currently connected to something -- three questions that vary independently, not one overloaded enum wearing three names. Unlike the other two, `AttachmentState` has **no terminal state**: every state has a path back to `Detached`, because attachment cycles for the volume's whole life; ending that life is `Lifecycle`'s job, not this crate's. This is tested directly (`every_state_has_a_path_back_to_detached`), not just asserted from the transition table's shape. ### What Phase 5 deliberately does not include - **No `cloud-storage-capacity` crate.** `cloud-capacity` (Phase 4) tracks vCPU/memory, a two-dimensional quantity meaningful to compute placement; a byte-capacity pool for storage nodes is a real, different primitive, but building it now with no concrete allocator or storage control-plane phase to feed would produce exactly the kind of crate that exists only to be depended on later without doing anything itself today -- the thing this workspace's layering rule explicitly prohibits (see "Layering" above). It's deferred until a storage provisioning phase actually needs it. - **No object/blob storage crate and no `cloud-volume` crate.** Composing `cloud-checksum` + `cloud-redundancy` + `cloud-attachment` (and `cloud-provisioner`/`cloud-control-plane`) into an actual storage service is the same kind of composition Phase 4 deferred for compute (`cloud-compute`): it doesn't get built, or named, until it's a real composition of already-real primitives. - **No erasure-coding encoder/decoder.** `cloud-redundancy` models the arithmetic a real erasure code (e.g. Reed-Solomon) guarantees -- `data_shards` of `data_shards + parity_shards` shards reconstruct the original -- without implementing the encoding itself, which is a substantial cryptographic/mathematical undertaking out of scope for a placement/durability-accounting primitive. ## Phase 6: database primitives Compute (Phase 4) needed execution state, capacity, and images. Storage (Phase 5) needed integrity, durability, and attachment state. Database-shaped resources need a third, disjoint set of concepts -- none of the prior nine crates from those two phases apply here: | Crate | Owns | |---|---| | [`cloud-consistency`](./crates/cloud-consistency) | `ConsistencyLevel` (`Eventual`/`BoundedStaleness`/`Strong`): a total order plus `satisfies()` against a caller's minimum requirement | | [`cloud-migration`](./crates/cloud-migration) | `MigrationLedger`: enforces that schema migrations apply strictly sequentially and without gaps | | [`cloud-retention`](./crates/cloud-retention) | `RetentionPolicy`: which backups/snapshots are eligible for deletion, by age, with a floor protecting the most recent N regardless of age | ### Why the ordering is derived, not hand-rolled `ConsistencyLevel`'s three variants are declared weakest-to-strongest specifically so `#[derive(PartialOrd, Ord)]` produces the correct total order for free -- `Eventual < BoundedStaleness < Strong` falls out of the derive macro's own documented rule (variants compare by declaration position) rather than a hand-written `match` that could silently drift out of sync with the enum if a variant were ever reordered. `satisfies()` is then just `self >= required`: the derive does the real work, the method exists only to name the operation. ### Why `cloud-migration` and `cloud-retention` don't touch each other Both crates could plausibly want to know about each other -- a retention policy might seem like it should care whether a migration happened -- but they answer genuinely unrelated questions: `MigrationLedger` is about schema shape over time (has version `N`'s change been applied), `RetentionPolicy` is about data lifetime (is this snapshot's age past a threshold, and is it protected by the floor). Coupling them would mean forcing every caller of one to drag in a concept the other owns. `RetentionPolicy` depends on `cloud-types` (for `ResourceId` and `Timestamp`) precisely because those are the workspace's own shared value types, not because it needs anything migration- or consistency-specific. ### What Phase 6 deliberately does not include - **No `cloud-database` (or similarly named) crate, and no `cloud-table`/`cloud-index`/`cloud-query` crates.** Composing `cloud-consistency` + `cloud-migration` + `cloud-retention` (and earlier phases' `cloud-provisioner`/`cloud-control-plane`) into an actual database service is the same kind of composition Phases 4 and 5 deferred for compute and storage: it doesn't get built, or named, until it's a real composition of already-real primitives. - **No query language, execution engine, or storage engine.** This phase's crates answer "what guarantee is this database offering," "is the schema in a consistent state," and "what data may be discarded" -- not "how is data stored or queried," which is a vastly larger undertaking out of scope for a primitives phase. - **`cloud-migration` does not run migrations.** It has no concept of SQL, DDL, or any specific transformation -- only the version-ordering invariant. Actually executing a migration's contents is a concern of whatever future service calls this ledger, exactly as `cloud-reconciler` (Phase 2) computes a lifecycle transition path without executing whatever makes a resource actually reach the next state. ## Phase 7: messaging primitives Compute, storage, and database (Phases 4-6) each needed their own disjoint set of concepts. Messaging-shaped resources (queues, topics, pub/sub) need a fourth, equally disjoint set -- the last of the four service categories this repository's own README names before AWS-shaped service composition begins: | Crate | Owns | |---|---| | [`cloud-delivery`](./crates/cloud-delivery) | `DeliverySemantics` (`AtMostOnce`/`AtLeastOnce`/`ExactlyOnce`): a genuine **partial order**, not a total one | | [`cloud-visibility`](./crates/cloud-visibility) | `MessageLease`: the visibility-timeout mechanism that actually implements at-least-once delivery, plus a dead-letter threshold on receive count | | [`cloud-fanout`](./crates/cloud-fanout) | `FanoutRegistry`: the topic-to-subscriber pub/sub topology | ### Why `cloud-delivery` doesn't derive `Ord`, unlike `cloud-consistency` Phase 6's `cloud-consistency::ConsistencyLevel` is a genuine total order: every pair of levels is comparable, and `#[derive(Ord)]`'s declaration-order rule gives the correct answer for free. Delivery semantics are different in kind, not just in domain: they vary along **two independent axes** -- whether loss is tolerated, and whether duplication is tolerated. `AtMostOnce` (loss allowed, duplication forbidden) and `AtLeastOnce` (loss forbidden, duplication allowed) are **incomparable**: neither is "stronger" than the other, because each permits something the other categorically forbids. A derived (or hand-rolled linear) ordering would have to put one before the other arbitrarily, and `satisfies()` would then give a wrong answer for that pair. `cloud-delivery` instead checks both axes explicitly, which correctly reports `AtMostOnce.satisfies(AtLeastOnce)` and its reverse as both `false`, while `ExactlyOnce` (which forbids both) satisfies every requirement. This is worth stating explicitly because it would have been easy, and wrong, to copy Phase 6's pattern here without checking whether the underlying relationship was actually total. ### Why `cloud-visibility` is a separate crate from `cloud-delivery` `DeliverySemantics` *declares* a guarantee; `MessageLease` is the actual mechanism (a hide-until-deadline lease plus a receive counter) that makes `AtLeastOnce` true in practice -- a message that isn't acknowledged in time becomes visible again automatically, which is how "never loses, may duplicate" is achieved mechanically rather than merely claimed. Keeping the declaration and the mechanism in separate crates mirrors `cloud-lifecycle` (Phase 1, a state machine) staying separate from `cloud-provisioner` (Phase 2, what actually drives state transitions) -- a semantics type and the engine that upholds it are different kinds of thing even when tightly related. ### What Phase 7 deliberately does not include - **No `cloud-queue`/`cloud-topic` (or similarly named) crate.** Composing `cloud-delivery` + `cloud-visibility` + `cloud-fanout` (and earlier phases' `cloud-provisioner`/`cloud-control-plane`) into an actual messaging service is the same composition every prior phase deferred for its own service category: it doesn't get built, or named, until it's real. - **`cloud-fanout` does not deliver anything.** It owns the topic-to-subscriber mapping only -- no idea what a message is, no retry, no per-subscriber `MessageLease`. Actually fanning a published message out (and tracking each subscriber's own lease) is a future service's job, exactly as the Phase 3 `Arn` registry owns a name-to-resource mapping without touching provisioning. - **No message ordering (FIFO) primitive.** Ordering guarantees interact with partitioning and fanout in ways that need a concrete queue/topic shape to be meaningful; modeling it against primitives alone risked producing an untestable abstraction. ## Phase 8: `cloud-compute`, the first real composed service Phases 4-7 built sixteen primitive crates across four service categories (compute, storage, database, messaging) and, at every single one, explicitly deferred composing them into anything -- "no `cloud-compute` (or similarly named) crate exists yet," repeated almost verbatim in each phase's own section above. Phase 8 is where that deferral ends for compute, the category with the most complete primitive set: `cloud-runtime` and `cloud-capacity` and `cloud-image` (Phase 4), plus `cloud-resource-registry` (Phase 3) and the Phase 1/2 resource model, are enough to build a real "launch an instance" operation without inventing a single new primitive. | Crate | Owns | |---|---| | [`cloud-compute`](./crates/cloud-compute) | `ComputeService`: `launch`/`transition_runtime`/`terminate`, composing eleven existing crates | ### Why this doesn't reuse `cloud-provisioner`/`cloud-control-plane` Phase 2's pipeline is deliberately generic: it works for any resource type and places with `cloud_scheduler::place_least_loaded`, which takes no capacity into account because most resources (a policy, a bucket in later phases) have none to check. `ComputeService` needs `place_least_loaded_with_capacity` (Phase 4) instead, since an instance genuinely cannot be placed somewhere without room for it. Two ways existed to get there: teach the generic pipeline about vCPU/memory (a one-off special case baked into a crate every other resource type also goes through -- exactly the anti-pattern the non-negotiable rule exists to prevent), or have `cloud-compute` run its own pipeline directly against the same underlying primitives. `cloud-compute` does the latter: it reimplements the shape of `AUTHORIZE -> VALIDATE -> PLAN -> APPLY` (policy check, quota reservation, capacity-aware placement, resource construction plus ARN registration) with the same rollback discipline `cloud-provisioner` established, but calling `cloud-capacity`/`cloud-image`/`cloud-runtime` directly rather than through a generic layer that doesn't know they exist. ### The rollback chain `launch` upholds `launch` reserves, in order: a quota unit, then placement capacity (which `place_least_loaded_with_capacity` reserves atomically with its own AZ-usage increment), then an ARN registration. A failure at any later stage releases every earlier reservation before returning -- tested directly for the capacity-exhaustion case (`QuotaExceeded` from `PLAN` rolls back the `VALIDATE`-stage quota unit) exactly as `cloud-provisioner`'s own tests do for its stages. ### `RuntimeState` lives in its own store, deliberately `ComputeService` keeps `RuntimeState` in a separate map from `Resource`, not as a field inside the resource or its payload. This is not an implementation shortcut: `cloud-runtime`'s own docs (Phase 4) describe execution state and the resource record as two things that vary independently, and storing them together would re-couple what Phase 4 deliberately kept apart. `transition_runtime` touches only the runtime store; a resource's own `version`/`updated_at` never changes just because the instance was started or stopped. ### What Phase 8 deliberately does not include - **No image deregistration, no instance resize, no attached volumes.** `terminate` releases exactly what `launch` reserved (quota, capacity); nothing else about an instance's lifecycle beyond launch/run/stop/terminate is modeled yet. - **`cloud-storage`, `cloud-database`, `cloud-messaging` (or similarly named) services do not exist yet.** Phase 8 is compute's turn only -- the other three service categories built in Phases 5-7 remain primitives without a composed service, for exactly the reason this phase just finished demonstrating takes real, deliberate design work rather than a copy-paste of `cloud-compute`'s shape. - **No HTTP/gRPC API surface.** `control-api` remains deferred from Phase 2's own reasoning: an API layer belongs after the service it exposes is itself real, and `cloud-compute` is the first phase where that precondition starts to hold for any one category. ## Phase 9: `cloud-storage`, the second composed service Phase 8's closing note named exactly this: "the other three primitive categories built in Phases 5-7 remain uncomposed." Phase 9 composes the second -- storage -- following `cloud-compute`'s own shape deliberately rather than inventing a new one: a service composes already-real primitives, adds no new primitive of its own, and keeps the same `AUTHORIZE -> VALIDATE -> APPLY`-shaped pipeline with full rollback. | Crate | Owns | |---|---| | [`cloud-storage`](./crates/cloud-storage) | `StorageService`: `create_volume`/`transition_attachment`/`delete_volume`, composing nine existing crates | ### What's different from `cloud-compute`, and why - **No AZ placement.** `cloud-compute` places instances via `cloud_scheduler::place_least_loaded_with_capacity` because `cloud-capacity` (Phase 4) gives it a real per-AZ vCPU/memory pool to place against. Phase 5 deliberately never built a `cloud-storage-capacity` equivalent -- "no concrete allocator to feed" -- and Phase 9 doesn't retroactively invent one just to mirror `cloud-compute`'s shape. A volume's size is tracked the same way `cloud-compute` tracks instance count: an account-level `cloud-quota` reservation (`"storage_bytes"`), real and tested, but not AZ-scoped. - **A real, new business rule**: `delete_volume` refuses to proceed unless the volume's `AttachmentState` is `Detached`, checked before any lifecycle reconciliation or quota release happens. This is the first place in the workspace a service-level operation is gated by a *different* primitive's current state (attachment) rather than just its own resource's lifecycle -- a real cross-primitive invariant, not a restatement of something `cloud-attachment` or `cloud-lifecycle` already enforces on its own. - **`transition_attachment` mirrors `cloud-compute::transition_runtime` exactly**: a thin, single-step wrapper around the primitive's own `transition_to`, leaving the higher-level operation (`delete_volume`, parallel to `terminate`) to own the multi-step, side-effecting case. ### What Phase 9 deliberately does not include - **No attachment target validation.** `transition_attachment` moves a volume's own state machine; it does not check that some real compute instance exists to attach to, or call into `cloud-compute` to verify one. Two service crates directly depending on each other's internal stores would be a tighter coupling than either has needed so far -- reconciling "does the thing I'm attaching to actually exist" across two independent services is an orchestration concern for whatever future layer calls both, exactly as `cloud-fanout` (Phase 7) doesn't verify a subscriber id names a real resource either. - **No `cloud-database`/`cloud-messaging` (or similarly named) services yet.** Phase 9 is storage's turn only. - **No snapshots, no `cloud-checksum`/`cloud-retention` integration.** A volume in this phase has no content-integrity check and no backup lifecycle; wiring those Phase 5/6 primitives in is future work once a snapshot concept exists to attach them to. ## Phase 10: `cloud-database`, the third composed service The third of the four service categories (Phases 5-7) gets composed, following exactly the shape `cloud-compute` and `cloud-storage` established. | Crate | Owns | |---|---| | [`cloud-database`](./crates/cloud-database) | `DatabaseService`: `create_database`/`apply_migration`/`create_snapshot`/`expire_snapshots`/`delete_database`, composing ten existing crates | ### `cloud-retention` finally gets a stateful caller Phase 6 built `RetentionPolicy::eligible_for_deletion` as a pure function: given "now" and a list of `(id, created_at)` pairs, it computes which ids a policy would allow deleting. Nothing before this phase actually *kept* such a list anywhere. `cloud-database` is the first thing that does: `create_snapshot` appends to a real, per-database snapshot list, and `expire_snapshots` calls `eligible_for_deletion` against it and then actually removes what comes back -- the stateful half of a primitive Phase 6 deliberately left as pure computation. This also resolves Phase 9's own deferral note on `cloud-storage` ("wiring \[retention\] in is future work once a snapshot concept exists to attach them to"): the snapshot concept now exists, here, in the service where it was always going to make more sense (a database's point-in-time backups, not a raw block volume). ### The delete-time gate, and how it differs from `cloud-storage`'s `cloud-storage::delete_volume` refuses based on another primitive's *current state* (`AttachmentState != Detached`). `cloud-database::delete_database` refuses based on **this service's own recorded state** (a non-empty snapshot list) rather than a different primitive's state machine -- a different kind of cross-cutting check, but the same underlying discipline: a service-level delete operation must look past its own resource's `Lifecycle` before deciding it's safe to proceed. ### Why `apply_migration` is a thin pass-through Exactly like `cloud-compute::transition_runtime` and `cloud-storage::transition_attachment`, `apply_migration` does nothing beyond delegating to the primitive's own method (`MigrationLedger::apply`) and keeping the per-database map in sync. The pattern across all three services is now well-established: a primitive's own state-changing method is never reimplemented at the service layer, only looked up, called, and stored back. ### What Phase 10 deliberately does not include - **No query execution, no schema DDL.** `apply_migration` records that a version was applied; it has no idea what SQL or transformation that version represents, exactly as `cloud-migration` itself (Phase 6) does not run migrations. - **No `cloud-messaging` (or similarly named) service yet.** Phase 10 is database's turn only; messaging (Phase 7's primitives) remains the one service category not yet composed. - **`create_snapshot` does not use `cloud-checksum`.** A snapshot here is just an id and a creation time -- verifying a snapshot's actual content integrity would need the snapshot to contain real bytes, which this phase's model doesn't have. ## Phase 11: `cloud-messaging`, the fourth and last composed service The fourth and final service category (Phases 5-7) gets composed, completing the set `cloud-compute`, `cloud-storage`, and `cloud-database` began. Every service category a primitive phase left deferred is now real. | Crate | Owns | |---|---| | [`cloud-messaging`](./crates/cloud-messaging) | `MessagingService`: `create_queue`/`create_topic`/`subscribe`/`publish`/`enqueue`/`receive`/`delete_message`/`delete_queue`/`delete_topic`, composing eleven existing crates | ### Two resource kinds, not one `cloud-compute`, `cloud-storage`, and `cloud-database` each had exactly one top-level resource (an instance, a volume, a database). `cloud-messaging` has two: a queue, which holds messages directly, and a topic, which holds none of its own -- only a fan-out list of subscriber queues via `cloud-fanout::FanoutRegistry`. This is precisely the composition `cloud-fanout`'s own Phase 7 doc comment named as deferred: "actually fanning a published message out to each subscriber ... is a concern for whatever future service composes all three \[`cloud-delivery`, `cloud-visibility`, `cloud-fanout`\]." `publish` is that composition: it fans a message out to every subscriber queue, inserting a fresh `cloud_visibility::MessageLease` into each one, so `receive` inherits real at-least-once mechanics (in-flight messages can't be redelivered until their visibility timeout elapses, and a message received too many times is dead-lettered) whether the message arrived by direct `enqueue` or by fan-out from a topic. ### `subscribe`'s gate: a new operation, an established gate shape `cloud-storage::delete_volume` (Phase 9) refused based on *another primitive's* current state (a volume's own `AttachmentState`). `cloud-messaging::subscribe` reuses that exact shape on a different operation: it refuses to link a queue to a topic unless the queue's own `cloud_delivery::DeliverySemantics` satisfies the topic's required semantics (`DeliverySemantics::satisfies`, Phase 7) -- an `AtMostOnce` queue cannot subscribe to a topic that requires `ExactlyOnce`. The cross-primitive check that Phase 9 first applied at delete time now applies at subscribe time instead. ### `delete_queue`/`delete_topic`'s gate: the `cloud-database` shape, twice `cloud-database::delete_database` (Phase 10) refused based on the service's *own* recorded state (a non-empty snapshot list) rather than another primitive's state machine. Both of `cloud-messaging`'s delete operations reuse that shape: `delete_queue` refuses while its inbox is non-empty, and `delete_topic` refuses while it still has subscribers -- two independent applications of the same "look past your own `Lifecycle` at your own recorded state before deleting" discipline, one per resource kind. ### `publish` validates every subscriber before delivering to any A duplicate message id colliding with one subscriber's existing inbox entry, discovered partway through a fan-out, must not leave some subscribers holding the message and others without it. `publish` checks every subscriber has room for the new id in a first pass, and only inserts into any of them in a second pass once every check has succeeded -- the same validate-before-apply discipline every pipeline in this workspace follows, applied here to a fan-out instead of a single resource's creation. ### What Phase 11 deliberately does not include - **No message body.** A message is just a `ResourceId`, exactly as a `cloud-database` snapshot (Phase 10) was just an id and a creation time -- there is no payload to store, checksum, or serialize. - **No ordering guarantee.** `BTreeMap`'s key order is used for determinism in tests and listings, not as a delivery-order promise; nothing in this phase models FIFO queues. - **No per-account message-count quota.** Exactly as `cloud-database` did not quota snapshots (only `"databases"` itself), this phase quotas `"queues"` and `"topics"` at creation time but not the messages flowing through them. - **Every service category originally identified back in Phases 4-7 is now composed.** A future phase's "what's still deferred" is no longer a service category; it is whatever composing these four services *together* (e.g. a compute instance's logs delivered through a queue) would require, which is deliberately out of scope here. ## Phase 12: `cloud-orchestration`, the first cross-service composition Phases 8-11 each composed primitives *within* one service category. Phase 12 is a different kind of composition: it composes two already-real **services** together, directly resolving a deferral `cloud-storage` (Phase 9) stated in its own words. | Crate | Owns | |---|---| | [`cloud-orchestration`](./crates/cloud-orchestration) | `attach_volume`/`detach_volume`: free functions coordinating `ComputeService` and `StorageService`, composing two existing services | ### Resolving `cloud-storage`'s own deferral, verbatim Phase 9's doc comment said this explicitly: "`cloud-storage` doesn't validate attachment targets -- attaching a volume moves only its own `AttachmentState`; whether the id it's attached to names a real `cloud-compute` instance is an orchestration concern for whatever future layer calls both services." `cloud-orchestration` is that layer. `attach_volume` takes a `&ComputeService` and a `&mut StorageService` together, checks the instance exists and is not `Terminating`/ `Terminated`, and only then drives the volume's attachment transitions. An unknown or terminated instance leaves the volume's `AttachmentState` completely untouched -- validate before mutate, the same discipline every pipeline in this workspace already follows, just applied across a service boundary instead of within one service's own stages. ### Free functions, not a third stateful service Unlike `cloud-compute`/`cloud-storage`/`cloud-database`/`cloud-messaging`, `cloud-orchestration` owns no resource store, no quota, no policy, no event log -- it has nothing to persist between calls. Its two functions take `&ComputeService`/`&mut StorageService` directly and return, exactly the shape `cloud-provisioner` (Phase 2) already established for a pipeline with nothing of its own to hold onto. A caller keeps owning both services; `cloud-orchestration` only ever borrows them for the duration of one call. ### Why there is no rollback to write Every earlier multi-stage pipeline in this workspace (`cloud-provisioner`, `cloud-compute::launch`, `cloud-database::create_database`, ...) needed rollback because a later stage could fail after an earlier stage had already reserved something real (quota, capacity, a name). `attach_volume` has no such stage: once the instance check passes, `Attaching -> Attached` is unconditionally valid in `cloud-attachment`'s own transition table (Phase 5) from the exact state `attach_volume` itself just placed the volume into. There is no failure mode between those two calls to roll back from, and the doc comment says so explicitly rather than leaving a reader to wonder why this phase, alone among the pipeline-shaped crates, has no rollback path. ### Why `detach_volume` doesn't check `compute` at all Detaching is presumed always safe, regardless of the instance's state -- including an instance that has since been terminated, or was deleted entirely. This mirrors real block-storage services, which support a force-detach specifically for the case where the thing a volume was attached to is already gone: the operation exists to free the volume, not to protect an instance that no longer needs protecting. ### What Phase 12 deliberately does not include - **No orchestration for `cloud-database` or `cloud-messaging`.** This phase resolves exactly the one deferral `cloud-storage` stated; wiring a database or a queue to a compute instance is a different cross-service question with no equivalent stated deferral yet. - **No compute-instance-to-queue log delivery**, the example Phase 11's own closing note raised as one of many possible future cross-service compositions. This phase picks the one with an existing, explicit deferral behind it rather than inventing a new composition with no prior art in this workspace to resolve. - **No new state machine.** `cloud-orchestration` reads `cloud-runtime::RuntimeState` and drives `cloud-attachment::AttachmentState` -- both already exist; this phase adds no new states and no new transitions to either. ## Phase 13: the IAM surface -- credentials, sessions, policy documents `cloud-identity`'s own Phase 1 doc comment named this explicitly: "the full IAM surface (credentials, sessions, federation, policy documents-as-JSON) is Phase 13 in the roadmap and is not pulled forward." Phase 13 is that surface -- three new primitive crates, the same shape Phases 4-7 each used (multiple disjoint primitives, no service composition yet), rather than a service. A future IAM service composing them is deferred exactly as `cloud-compute` was deferred until Phase 8, well after Phase 4 built its primitives. | Crate | Owns | |---|---| | [`cloud-credentials`](./crates/cloud-credentials) | `CredentialStore`: at most two `Active` credentials per principal, the real IAM rule AWS itself enforces | | [`cloud-session`](./crates/cloud-session) | `SessionStore`: assumed-role and federated sessions, valid only while un-revoked and unexpired | | [`cloud-policy-document`](./crates/cloud-policy-document) | `to_json`/`from_json`: a `cloud-policy::Policy` serialized to and parsed from a JSON-shaped document, via a hand-rolled parser restricted to exactly this schema | ### Why no real secret material `cloud-credentials` models a credential's *lifecycle* -- its id, the principal it belongs to, and whether it's `Active` or `Inactive` -- and nothing about its actual secret bytes. Generating a cryptographically secure secret needs a real source of randomness, which this zero-external-dependency workspace deliberately doesn't pull in (every crate here depends only on other `cloud-*` crates). This mirrors `cloud-lifecycle` modeling a resource's lifecycle without knowing what the resource actually does: the state machine is real; the payload it governs is out of scope for the crate that owns the state machine. ### The real IAM rule: at most two active credentials Rather than invent a business rule, `cloud-credentials` implements one AWS itself enforces on its own IAM users: a principal may hold at most `MAX_ACTIVE_CREDENTIALS_PER_PRINCIPAL` (2) `Active` credentials at once. `Inactive` credentials don't count against the cap, so a principal can rotate safely -- create a new key, deactivate the old one, delete it later -- without ever exceeding the limit at any point in between. Reactivating a credential re-checks the same cap `create` does, since reactivation can push a principal back over the limit just as creation can. ### `cloud-session`'s two independent invalidity paths A [`Session`] is valid only while it is **both** un-revoked and unexpired -- deliberately the same two-independent-axis shape `cloud_visibility::MessageLease` (Phase 7) already established for at-least-once redelivery, where a message became undeliverable either by being in flight or by exceeding its receive count. Here, a session becomes invalid either by outliving its `expires_at` or by an explicit `SessionStore::revoke` call ahead of that natural expiry; neither condition implies the other, and `Session::is_valid` checks both. `SessionSource` captures the two ways Phase 13's own scope named a session can come to exist -- `AssumedRole` and `Federated` -- covering both "sessions" and "federation" from `cloud-identity`'s original deferral in one type, rather than a separate `cloud-federation` crate for what is, underneath, still just a session with a different origin. ### `cloud-policy-document`'s hand-rolled, deliberately narrow parser Serializing a `Policy` to JSON and back needs *something* that understands JSON syntax, and pulling in a general-purpose JSON crate would break this workspace's zero-external-dependency posture -- exactly the same posture `cloud-checksum` (Phase 5) upheld by implementing CRC-32 from scratch, and `cloud-types::Arn` (Phase 1) upheld with its own hand-rolled `Display`/`FromStr` pair instead of a generic serialization framework. `cloud-policy-document::from_json` is a parser restricted to exactly the fixed schema a policy document needs -- strings, arrays, and objects, no numbers, booleans, or `null` -- not a general JSON value type. It also deliberately does not replicate AWS's own polymorphism, where `Principal`/`Action`/`Resource` may each be either a bare string or an array interchangeably: here `Action`/`Resource` are always arrays, and `Principal` is either the literal string `"*"` or an array, never a bare non-`"*"` string. This is the same kind of deliberate scope-narrowing `cloud-policy`'s own `matches_pattern` already applies (a single trailing-wildcard position, not a general glob engine) -- one shape per field is all this phase needs. ### `Policy::statements`, a small accessor added to an earlier crate `cloud-policy-document` needs to read a `Policy`'s statements to serialize them, and `Policy` (Phase 1) never exposed a way to do that from outside its own crate. Rather than duplicate that data somewhere else, `cloud-policy` gained one narrow, backward-compatible accessor, `statements(&self) -> &[Statement]` -- the same precedent `cloud-scheduler` (Phase 2) already set when it gained `place_least_loaded_with_capacity` in Phase 4: a later phase's composition need is satisfied by adding to the crate that already owns the data, not by duplicating it or routing around it. ### What Phase 13 deliberately does not include - **No IAM service composing these three crates.** Exactly as Phases 4-7 each built primitives with service composition deferred to Phases 8-11, Phase 13 is primitives only; a service that creates credentials/sessions/policy documents together, with quota, events, and an `Arn`-resolvable identity, is a future phase's concern. - **No real cryptographic secret material, signing, or verification** for `cloud-credentials` -- see above. - **No `cloud-federation` crate.** Federation is modeled as one `SessionSource` variant in `cloud-session`, not a fourth crate, since a federated identity's whole lifecycle *is* a session with a different origin. - **`cloud-policy-document` does not support AWS's bare-string-or-array polymorphism** for `Action`/`Resource`, and supports only the literal `"*"` (not an arbitrary bare string) for `Principal` -- see above. - **No resource-based policies** (a policy document attached to a specific resource rather than a principal) -- `cloud-resource::Resource`'s own Phase 1 doc comment already named this as a Phase 13 concept not pulled forward into `Resource` itself, and this phase's policy documents remain principal-facing, the same shape `cloud-policy` already evaluates against. ## Phase 14: `cloud-iam`, the fifth composed service Phase 13 built the IAM surface as three disjoint primitives, deliberately without a service, the same shape Phases 4-7 used before their own service phases arrived later. Phase 14 is that service: it composes `cloud-credentials`, `cloud-session`, and `cloud-policy-document` together with `cloud-policy` and `cloud-events`, following exactly the "compose already-real primitives, introduce none" discipline `cloud-compute`/`cloud-storage`/`cloud-database`/`cloud-messaging` (Phases 8-11) each established. | Crate | Owns | |---|---| | [`cloud-iam`](./crates/cloud-iam) | `IamService`: `create_credential`/`activate_credential`/`deactivate_credential`/`delete_credential`/`assume_role`/`federate`/`revoke_session`/`authorize`/`load_policy_document`/`export_policy_document`, composing five existing crates | ### The one real business rule: policy gates session creation Every composed service so far has called `Policy::authorize` before mutating -- but always to gate *another* service's own resource operation (`launch`, `create_volume`, `create_database`, `create_queue`). `IamService::assume_role` and `IamService::federate` are the first operations in this workspace where `cloud-policy` (Phase 1) governs something *within the IAM surface itself*: a principal now needs permission to assume a role or federate in at all, checked before any session is created, so a denied attempt leaves `cloud-session`'s store completely untouched. The mechanism is unchanged from every earlier phase; what's new is that IAM's own operations are finally subject to the same authorization discipline as everyone else's. ### `load_policy_document`/`export_policy_document`: the live policy, round-trippable `IamService` holds exactly one active `Policy`, and `load_policy_document` replaces it immediately -- every subsequent `authorize`/`assume_role`/ `federate` call is governed by the newly loaded policy, not just parsed data sitting inertly. `export_policy_document` is the inverse, so a policy can be extracted, edited as JSON, and reloaded. A malformed document leaves the previous policy in place: `self.policy = ...from_json(json)?` only assigns once the parse has already succeeded, so a failed `load_policy_document` call is a pure no-op on the active policy, consistent with every other fallible mutation in this workspace leaving state untouched on error. ### No accounts, regions, quota, or `Arn` -- deliberately Every prior composed service validated an `AccountId`/`RegionId` and reserved a `cloud-quota` unit before creating anything, and registered a resolvable `Arn` for what it created. `IamService` does none of these: real IAM systems (AWS's own included) are inherently global, not region-scoped, and neither a credential nor a session is provisioned infrastructure with a finite regional capacity or a canonical external name -- mirroring real IAM's own inconsistent resource model, where a role or user has an ARN but an access key or an STS session token does not. Omitting all four here is a deliberate design choice mirroring what IAM actually is, not an oversight or a gap to fill later. ### What Phase 14 deliberately does not include - **No resource-based policy attachment.** `IamService` holds one policy governing every principal; attaching a distinct policy to a specific resource remains the Phase 13 (IAM) concept `cloud-resource`'s own Phase 1 doc comment named as not pulled forward into `Resource`. - **No credential-to-session linkage.** A `Credential` and a `Session` are tracked independently; `IamService` does not model long-term credentials being exchanged for a short-term session the way real STS `GetSessionToken` does, since `cloud-credentials` has no secret material for such an exchange to authenticate against in the first place (Phase 13's own deferral). - **No policy versioning or multiple named policies.** `load_policy_document` replaces the single active policy outright; there is no history of prior policies, no policy identifiers, and no way to attach different policies to different principals within one `IamService`. ## Phase 15: `cloud-orchestration` gets a second composition, and its first rollback Phase 12 gave `cloud-orchestration` its first cross-service composition (`cloud-compute` + `cloud-storage`, resolving `cloud-storage`'s own Phase 9 deferral). Phase 15 gives it a second, in the same crate: `cloud-compute` + `cloud-messaging`, resolving a deferral `cloud-messaging` itself named in its own Phase 11 closing note -- "what remains deferred is composing these services *together* (e.g. a compute instance's logs delivered through a queue)." | Crate | Owns | |---|---| | [`cloud-orchestration`](./crates/cloud-orchestration) | Adds `transition_runtime_and_notify`/`terminate_and_notify` alongside Phase 12's `attach_volume`/`detach_volume` | ### Why this composition needs a rollback and Phase 12's didn't Phase 12's doc comment explained, at some length, why `attach_volume` needed no rollback: once its instance check passed, `cloud-attachment`'s own transition table made the second step (`Attaching -> Attached`) unconditionally valid, so there was no failure mode between the two steps to roll back from. `transition_runtime_and_notify` and `terminate_and_notify` are different: a `RuntimeState` transition genuinely can fail (an invalid jump, an unknown instance), and unlike `cloud-attachment`'s state machine, `RuntimeState` transitions are not generally reversible -- there is no transition that undoes an arbitrary runtime-state change the way `Detaching -> Detached` undoes `Attaching`. That asymmetry decides the ordering: both functions enqueue the notification message into `cloud-messaging` *first*, then attempt the compute mutation, then -- only if that mutation fails -- delete the message they just enqueued via `MessagingService::delete_message`. A failed transition or termination therefore never leaves a stray notification sitting in a queue, and this is the first rollback anywhere in this workspace that spans two independent services rather than undoing steps within one. ### What this composition still doesn't do - **No message body describing the event.** Exactly as `cloud-messaging` (Phase 11) itself deferred, a message is just a `ResourceId`; the caller chooses a `message_id` that means whatever it needs to mean (an event id, a correlation id), and `cloud-orchestration` does not interpret it. - **No notification on `launch` or on volume attach/detach.** This phase covers exactly the two operations its motivating deferral named (an instance's own lifecycle events); extending notification to other operations or other services is a separate question with no equivalent stated deferral yet. - **No fan-out to multiple queues.** Both functions take exactly one `queue_id`; delivering the same notification to several queues would need `cloud-messaging`'s own topic/subscriber model (`publish`), not a direct `enqueue`, and composing that is left for whenever a concrete need for it exists. ## Phase 16: `cloud-orchestration` gets a third composition, with database operations Phase 15 composed `cloud-compute` with `cloud-messaging`. Phase 16 adds a third composition in the same crate: `cloud-database` + `cloud-messaging`. Both [`apply_migration_and_notify`] and [`delete_database_and_notify`] follow the same enqueue-first, rollback-on-failure discipline, since database operations like schema migrations can fail (an invalid version jump, incompatible changes) and are typically not reversible (schema changes are one-way; deletions are final). | Crate | Owns | |---|---| | [`cloud-orchestration`](./crates/cloud-orchestration) | Adds `apply_migration_and_notify`/`delete_database_and_notify` alongside Phase 12's attach/detach and Phase 15's compute+messaging | ### Why database operations need the same rollback pattern Unlike [`attach_volume`]'s second step (which `cloud-attachment`'s own transition table made unconditionally valid once the first succeeded), and matching Phase 15's reasoning for `RuntimeState` transitions: database mutations can fail independently. Applying a migration version requires the prior version to exist; deleting a database requires all snapshots to have expired. These operations are also not generally reversible -- there is no `unapply_migration` that undoes an arbitrary schema change, and deletion is final. So both functions enqueue the notification message first, then attempt the database mutation, then delete the message if the mutation fails. This mirrors Phase 15 exactly, and is the second (and second-and-third) composition in this workspace spanning two independent services. ### What this composition still doesn't do - **No message body describing the event.** Exactly as Phase 15 deferred this, a message is just a `ResourceId`. - **No notification on `create_database` or other database operations.** This phase covers exactly the two operations with clear, stated use cases (migration and deletion); extending to other operations or other services is a separate question. - **No fan-out to multiple queues.** Both functions take exactly one `queue_id`, matching Phase 15's scope limitation. ## Definition of done, per crate Reusing the roadmap's own maturity levels, scoped to what a primitive crate (not a service) can claim: - **L1 (data model)** — the types exist, are validated at construction, and round-trip through their own `Display`/`FromStr` (or equivalent). - **L2 (deterministic local implementation)** — the crate's actual logic (a state-machine transition, a policy evaluation, a quota check) is implemented and produces the same result for the same input every time, with no hidden clock/randomness/iteration-order dependency. - **L8 (tests)** — every invariant claimed above has a passing test that would fail if the invariant were violated (not just a compiles-and-returns-something test). Every crate in this phase targets L1+L2+L8. Persistence (L5), distribution (L4), and formal verification (L9/L10, eventually via `verification-forge`) are explicitly out of scope until the control plane (Phase 2) gives them something real to attach to. ## Relationship to `verification-forge` Per the roadmap: `verification-forge` should eventually verify `cloud-forge`'s invariants (resource-id uniqueness, deny-dominates policy evaluation, lifecycle transitions being total functions, quota violations never committing), not become a runtime dependency of it. That work is not started in this phase — it requires `cloud-forge`'s own resource/lifecycle/policy model to exist and stabilize first, which is exactly what this phase builds. ## Quality gates Same discipline as the other two workspaces in this repository: no crate is considered done until `cargo test -p --release` passes, `cargo clippy -p --all-targets -- -D warnings` is clean, and `cargo fmt -p -- --check` is clean — checked after every crate, not batched at the end. `#![forbid(unsafe_code)]` in every crate. No `unwrap()`/`expect()` on anything derived from a caller's input -- a malformed resource id or an invalid policy input is exactly the kind of thing this substrate has to reject cleanly (a `CloudError`) rather than panic on. The only `expect()` calls in this phase (`cloud-region`'s AZ-name construction, `cloud-types::AzId::region()`) are on values built from a *already-validated* piece of the same type's own state -- provably unreachable to fail, and each carries a comment saying so -- never on anything a caller supplied that hasn't already gone through that type's constructor.