diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c2198b8d..b7e5e67c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -16,7 +16,7 @@ existing unbounded-tech quality provider plus the integration/* jobs below — | [`integration-js.yaml`](./integration-js.yaml) | yes | **This repo:** install, typecheck, test, build, and packed-consumer smoke test for `js/` | | [`on-pr-quality.yaml`](./on-pr-quality.yaml) | entry | **This repo** PR gate (not the consumer quality contract) | | [`on-push-main-version-and-tag.yaml`](./on-push-main-version-and-tag.yaml) | entry | **This repo** main → **vnext** tag | -| [`on-v-tag-publish.yaml`](./on-v-tag-publish.yaml) | entry | **This repo** crates.io + npm + `dctl` binary release | +| [`on-v-tag-publish.yaml`](./on-v-tag-publish.yaml) | entry | **This repo** crates.io + npm + `distributed` binary release | **Version tagging (anywhere):** `unbounded-tech/workflow-vnext-tag` **GitHub Release only (domain crates):** `unbounded-tech/workflow-simple-release` @@ -136,4 +136,4 @@ quality contract. ## Out of scope (for now) - crates.io publish reusable (framework still uses `unbounded-tech` publish helpers in `on-v-tag-publish.yaml`) -- image / GitOps promote (service scaffolds via `dctl`) +- image / GitOps promote (service scaffolds via `distributed`) diff --git a/.github/workflows/integration-distributed-cli.yaml b/.github/workflows/integration-distributed-cli.yaml index 1cca78be..fd48c487 100644 --- a/.github/workflows/integration-distributed-cli.yaml +++ b/.github/workflows/integration-distributed-cli.yaml @@ -1,7 +1,7 @@ name: distributed_cli Integration Tests # Reusable workflow: referenced via `uses: ./.github/workflows/integration-distributed-cli.yaml`. -# Runs the `dctl` integration tests, including the `#[ignore]`d manifest-harness +# Runs the `distributed` integration tests, including the `#[ignore]`d manifest-harness # e2e tests (describe/schema) that compile a fixture service via nested cargo. on: workflow_call: diff --git a/.github/workflows/integration-observability.yaml b/.github/workflows/integration-observability.yaml index a6d5c37f..9d98949c 100644 --- a/.github/workflows/integration-observability.yaml +++ b/.github/workflows/integration-observability.yaml @@ -89,7 +89,7 @@ jobs: kubeconform -v - name: Scaffold service with metrics and tracing run: | - cargo run -p distributed_cli --bin dctl -- scaffold observability-orders \ + cargo run -p distributed_cli --bin distributed -- scaffold observability-orders \ --path target/tmp/scaffold-observability \ --store in-memory --transport http \ --metrics prometheus --tracing --gitops \ diff --git a/.github/workflows/on-pr-quality.yaml b/.github/workflows/on-pr-quality.yaml index 37c13ad4..56061b40 100644 --- a/.github/workflows/on-pr-quality.yaml +++ b/.github/workflows/on-pr-quality.yaml @@ -8,6 +8,30 @@ concurrency: cancel-in-progress: true jobs: + contracts: + name: Run fail-fast contract lifecycle check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Resolve PR merge-base + id: base + run: | + git fetch origin "${{ github.base_ref }}" --depth=1 + base=$(git merge-base HEAD "origin/${{ github.base_ref }}" || true) + echo "sha=${base}" >> "$GITHUB_OUTPUT" + echo "merge-base=${base}" + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run contracts check + run: | + if [ -f contracts/catalog.json ]; then + cargo run -p distributed_cli --quiet -- contracts check --root . --catalog contracts/catalog.json --output json + else + echo "No contracts/catalog.json yet; gate is present and ready for producers." + fi + # Framework monorepo keeps its existing quality provider. The reusable # quality.yaml in this directory is the contract for *consumers* (domain # crates / Distributed libraries), not for this workspace's entry pipeline. @@ -34,16 +58,20 @@ jobs: uses: ./.github/workflows/integration-kafka.yaml distributed-cli: + needs: [contracts] uses: ./.github/workflows/integration-distributed-cli.yaml observability: uses: ./.github/workflows/integration-observability.yaml graphql: + needs: [contracts] uses: ./.github/workflows/integration-graphql.yaml e2e-ui: + needs: [contracts] uses: ./.github/workflows/integration-e2e-ui.yaml js-client: + needs: [contracts] uses: ./.github/workflows/integration-js.yaml diff --git a/.github/workflows/on-v-tag-publish.yaml b/.github/workflows/on-v-tag-publish.yaml index 7be814a0..28c35b71 100644 --- a/.github/workflows/on-v-tag-publish.yaml +++ b/.github/workflows/on-v-tag-publish.yaml @@ -40,7 +40,7 @@ jobs: manifest_path: distributed_macros/Cargo.toml cargo_publish_args: "--locked" - # distributed_cli (the `dctl` binary + generation library) has no internal + # distributed_cli (the `distributed` binary + generation library) has no internal # workspace dependencies, so it publishes independently of the macros/core crates. publish-cli: needs: release-preflight @@ -116,6 +116,6 @@ jobs: contents: write uses: unbounded-tech/workflows-rust/.github/workflows/release.yaml@v2.3.0 with: - binary_name: dctl + binary_name: distributed # Cargo.lock is intentionally untracked for this library workspace. - build_args: "--release --package distributed_cli --bin dctl" + build_args: "--release --package distributed_cli --bin distributed" diff --git a/.gitignore b/.gitignore index 3f13a57a..619edf85 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ tests/workshop-service/ui/build/ tests/workshop-service/ui/.svelte-kit/ tests/workshop-service/**/target/ tests/workshop-service/*.db + +# fixture crate build artifacts +tests/fixtures/**/target/ diff --git a/Cargo.toml b/Cargo.toml index 715dd97f..79c66361 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ required-features = ["graphql", "sqlite"] [features] default = [] +application-runtime = [] +runtime = ["application-runtime"] emitter = ["dep:event-emitter-rs"] metrics = [] http = ["dep:axum", "dep:reqwest", "dep:tokio"] @@ -48,6 +50,7 @@ graphql = ["dep:async-graphql", "dep:async-graphql-axum", "dep:hmac", "dep:jsonw [dependencies] async-nats = { version = "0.49", optional = true } +async-trait = "0.1" async-graphql = { version = "7", optional = true } async-graphql-axum = { version = "7", optional = true } axum = { version = "0.8", optional = true } @@ -77,6 +80,9 @@ tracing-opentelemetry = { version = "0.33", default-features = false, optional = uuid = { version = "1", features = ["v7"] } [build-dependencies] +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +sha2 = "0.10" tonic-build = { version = "0.14", default-features = false, features = ["transport"] } [dev-dependencies] diff --git a/Makefile b/Makefile index 6ae192f0..5262d84a 100644 --- a/Makefile +++ b/Makefile @@ -43,3 +43,9 @@ compose-up: compose-down: $(DOCKER_COMPOSE) down $(COMPOSE_DOWN_FLAGS) + +.PHONY: contracts-check + +## Read-only aggregate contract lifecycle check (never writes tracked files). +contracts-check: + $(CARGO) run -p distributed_cli --quiet -- contracts check --root . --catalog contracts/catalog.json --output human diff --git a/README.md b/README.md index aafd8034..4538428f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ stack of deliberate files. Start here: │ │ │ @hops-ops/distributed ├── mutations → aggregates │ causal replica + commands ├── Atomic rows (blob) with events - │ dctl-generated ops └── projector rows (todos, chat) + │ distributed-generated ops └── projector rows (todos, chat) ``` JS package deep-dive: [`js/README.md`](js/README.md). @@ -93,7 +93,7 @@ e2e-ui boots **Zitadel** for the browser path; the three stacks prove the same | GraphQL query service | Filters, order, pagination, relationships, RBAC, live subs, causal mutations | | npm JS client | Artifacts, HTTP/WS transport, **causal replica**, diagnostics, SvelteKit/React | | microsvc | One handler inventory on HTTP, gRPC, bus, GraphQL, or direct dispatch | -| `dctl` | Scaffold, SQL/Atlas/SDL, **client-manifest / client** codegen | +| `distributed` | Scaffold, SQL/Atlas/SDL, **client-manifest / client** codegen | ## Use as a Dependency @@ -184,7 +184,7 @@ Most application crates should depend on `distributed` only. The proc macros (`#[sourced]`, `#[digest]`, `#[derive(ReadModel)]`, `#[derive(Snapshot)]`) are re-exported from `distributed`; do not add `distributed_macros` directly unless you are working on the macro crate itself. The `distributed_cli` crate installs -the `dctl` tooling and is not needed as a runtime dependency unless you are +the `distributed` tooling and is not needed as a runtime dependency unless you are embedding the CLI in another command such as `hops service`. ## Quick Start (library) @@ -1515,7 +1515,7 @@ let loaded = repo - **Internal loads:** PK-anchored includes — `store.workspace().load(...).include(...).one()` (one-level, opt-in). - **Schema lifecycle:** `ReadModelSchemaRegistry` + adapter for migration artifacts - and startup verification; `dctl schema` / `distributed_manifest()` for SQL. + and startup verification; `distributed schema` / `read_model_catalog()` for SQL. - **Non-goals:** public query APIs belong on the GraphQL layer below (not the ORM include loader); do not write projections outside the projection path. @@ -1531,7 +1531,7 @@ This is the public query/command edge for full-stack apps. The companion TypeScript package [`@hops-ops/distributed`](js/) (see [`js/README.md`](js/README.md)) supplies transport, a normalized causal replica, command runtime, diagnostics, and SvelteKit/React adapters. End-to-end template: -[`tests/e2e-ui/`](tests/e2e-ui/). Scaffold with `dctl scaffold … --query-api`. +[`tests/e2e-ui/`](tests/e2e-ui/). Scaffold with `distributed scaffold … --query-api`. Example playground: `cargo run --example graphiql --features "graphql,sqlite"`. ### Enable @@ -1546,7 +1546,7 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] } `graphql` implies `http` (Axum router, including `/graphql/ws`). SDL helpers under `distributed::graphql::{naming,sdl}` compile without the feature so -`dctl schema --format graphql` works in tooling crates. +`distributed schema --format graphql` works in tooling crates. ### Scope @@ -1591,7 +1591,7 @@ let service = Service::new() // Optional: keep commands on GraphQL/bus/direct dispatch only. .without_http_command_routes(); -let engine = GraphqlEngine::from_manifest(&manifest, &repository)? +let engine = GraphqlEngine::from_schema_catalog(&manifest, &repository)? // This exact executable inventory is the only mutation source. .service(&service) // Stable nonzero deployment secret shared by replicas of this endpoint. @@ -1704,14 +1704,14 @@ GraphiQL is a **developer** tool. Default headers in the playground trust ```bash # Optional human-readable GraphQL SDL artifact -dctl schema --format graphql --out schema.graphql +distributed schema --format graphql --out schema.graphql git diff --exit-code schema.graphql # drift gate ``` The Rust `Service` inventory and GraphQL `Surface` IR are the source of truth for schema, authorization, commands, optimistic effects, and client artifacts. -`dctl client-manifest` exports one role or named application surface, and -`dctl client` compiles that manifest with co-located `.graphql` operations into +`distributed client-manifest` exports one role or named application surface, and +`distributed client` compiles that manifest with co-located `.graphql` operations into typed query/live/command modules. Common and elevated applications use separate manifest entrypoints, document sets, generated directories, virtual modules, and request-local replicas; an admin superset is never bundled into the common @@ -1767,8 +1767,8 @@ make check-client # generated user/admin clients are current Generate app clients from the Rust surface: ```bash -dctl client-manifest … # export role/app surface IR -dctl client … # compile co-located .graphql → typed modules +distributed client-manifest … # export role/app surface IR +distributed client … # compile co-located .graphql → typed modules ``` See [`js/README.md`](js/README.md) for package API and packaging. @@ -1934,17 +1934,18 @@ A v1 event automatically chains through v1→v2→v3; a v2 event only goes throu - **No stored data modified**: Upcasters are read-time transformations. - **Zero overhead when unused**: Aggregates with no upcasters take the fast hydration path. -## Service CLI (`dctl`) +## Service CLI (`distributed`) -The [`distributed_cli`](distributed_cli/) crate ships `dctl` — tooling to scaffold -services, inspect a service's project manifest, and render schema artifacts. It is +The [`distributed_cli`](distributed_cli/) crate ships `distributed` — tooling to scaffold +services, inspect a service's logical application artifact, and render physical +read-model schema artifacts. It is also a library, so `hops` mounts the same commands under `hops service` (anything -below as `dctl ` works as `hops service `). +below as `distributed ` works as `hops service `). The CLI exists to keep the generated and handwritten parts of a back-end service separate. A Distributed service should usually reduce to a small custom surface: aggregate models, command/event handlers, read models, and the occasional -handwritten integration. The framework, macros, manifest, and CLI generate the +handwritten integration. The framework, macros, application artifacts, and CLI generate the repeatable wiring around that surface. That boundary matters for AI-assisted development. AI generation is @@ -1957,9 +1958,9 @@ shapes. Boilerplate service setup, manifest discovery, schema output, and GitOps artifacts stay deterministic. ```bash -cargo install distributed_cli # installs `dctl` +cargo install distributed_cli # installs `distributed` -dctl scaffold orders \ +distributed scaffold orders \ --model order \ --read-models \ --command order.submit \ @@ -1972,8 +1973,8 @@ dctl scaffold orders \ cd orders cargo test -dctl describe # print the project manifest as JSON -dctl schema --dialect postgres # render migration SQL from read models +distributed describe # print the ApplicationManifest as JSON +distributed schema --dialect postgres # render migration SQL from read models ``` Use the event-storming board as the input: @@ -2004,26 +2005,28 @@ a **private** listener — unauthenticated by design. applicable. Do **not** label metrics with `user_id`, `tenant_id`, free-form paths, or raw command input (unknown commands bucket as `message=unknown`). -`describe`/`schema` compile your crate and call its `distributed_manifest()` -entrypoint (override with `--entrypoint`), which registers the [read -models](#read-models) and tables that define the schema: +`describe`/`schema` compile your crate and call explicit artifact entrypoints +(override with `--entrypoint`). `describe` reads the logical +`application_manifest()` owner; `schema` reads the separate +`read_model_catalog()` owner that registers the [read models](#read-models) and +tables defining physical schema: ```rust,ignore -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { - distributed::DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> distributed::ReadModelCatalog { + distributed::ReadModelCatalog::new("orders").read_model::() } ``` ### Apply schema in-cluster with Atlas -`dctl schema --format atlas` wraps the desired-state SQL into an `AtlasSchema` +`distributed schema --format atlas` wraps the desired-state SQL into an `AtlasSchema` (`db.atlasgo.io/v1alpha1`) for the [ariga atlas-operator](https://github.com/ariga/atlas-operator), so migrations apply declaratively in-cluster. The resource is written to **stdout** — redirect it wherever you keep schema manifests (a file, or a separate -GitOps repo); `dctl` does not choose a location for it. +GitOps repo); `distributed` does not choose a location for it. ```bash -dctl schema --format atlas --name orders --db-secret orders-db > orders.schema.yaml +distributed schema --format atlas --name orders --db-secret orders-db > orders.schema.yaml ``` Use `--db-secret`/`--db-secret-key` for a Secret reference (GitOps-friendly) or diff --git a/build.rs b/build.rs index eff54296..a0f3b91b 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,79 @@ +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +const INVENTORY_PATH: &str = "migrations/inventory.json"; +const INVENTORY_VERSION: u32 = 1; +const MAX_INVENTORY_BYTES: usize = 1024 * 1024; +const MAX_SQL_BYTES: usize = 4 * 1024 * 1024; +const MAX_MIGRATIONS: usize = 256; +const MAX_JSON_DEPTH: usize = 24; +const MAX_TOTAL_ENTRIES: usize = MAX_MIGRATIONS * 4; +const MAX_TOP_LEVEL_ENTRIES: usize = 64; +const MAX_DIRECTORIES: usize = 4_096; +const MAX_SQL_FILES: usize = MAX_MIGRATIONS * 2; +const REDACTED_MIGRATION_PATH: &str = ""; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Inventory { + schema_version: u32, + migrations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Migration { + version: u64, + description: String, + sqlite: MigrationFile, + postgres: MigrationFile, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MigrationFile { + path: String, + sha256: String, +} + +#[derive(Clone, Copy)] +enum Dialect { + Sqlite, + Postgres, +} + +impl Dialect { + const ALL: [Self; 2] = [Self::Sqlite, Self::Postgres]; + + const fn name(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } + + const fn directory(self) -> &'static str { + match self { + Self::Sqlite => "migrations/sqlite", + Self::Postgres => "migrations/postgres", + } + } + + fn file(self, migration: &Migration) -> &MigrationFile { + match self { + Self::Sqlite => &migration.sqlite, + Self::Postgres => &migration.postgres, + } + } +} + fn main() { + emit_migration_inventory(); + // Only run gRPC codegen when the "grpc" feature is enabled. // Cargo sets CARGO_FEATURE_GRPC when compiling with --features grpc. if std::env::var("CARGO_FEATURE_GRPC").is_ok() { @@ -28,3 +103,473 @@ fn main() { tonic_build::manual::Builder::new().compile(&[service]); } } + +fn emit_migration_inventory() { + println!("cargo:rerun-if-changed={INVENTORY_PATH}"); + for dialect in Dialect::ALL { + // Watching the dialect roots also notices an unregistered SQL file + // being added; the explicit file lines below keep declared inputs + // visible in Cargo's build explanation. + println!("cargo:rerun-if-changed={}", dialect.directory()); + } + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR") + .expect("Cargo must provide CARGO_MANIFEST_DIR to the build script"), + ); + let root = fs::canonicalize(&manifest_dir) + .unwrap_or_else(|error| panic!("resolve repository root for migrations: {error}")); + let inventory_path = root.join(INVENTORY_PATH); + let bytes = read_bounded_file( + &root, + &inventory_path, + MAX_INVENTORY_BYTES, + "migration inventory", + ); + validate_json_nesting(&bytes).unwrap_or_else(|error| panic!("parse {INVENTORY_PATH}: {error}")); + let inventory: Inventory = serde_json::from_slice(&bytes) + .unwrap_or_else(|error| panic!("parse {INVENTORY_PATH}: {error}")); + validate_inventory(&root, &inventory); + + for migration in &inventory.migrations { + for dialect in Dialect::ALL { + println!("cargo:rerun-if-changed={}", dialect.file(migration).path); + } + } + + let out_dir = PathBuf::from( + std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR to the build script"), + ); + let generated_path = out_dir.join("migration_inventory.rs"); + let mut generated = + String::from("// Generated by build.rs from migrations/inventory.json; do not edit.\n"); + emit_dialect( + &mut generated, + "SQLITE_MIGRATIONS", + Dialect::Sqlite, + &inventory, + ); + emit_dialect( + &mut generated, + "POSTGRES_MIGRATIONS", + Dialect::Postgres, + &inventory, + ); + let mut file = File::create(&generated_path) + .unwrap_or_else(|error| panic!("create generated migration registration: {error}")); + file.write_all(generated.as_bytes()) + .unwrap_or_else(|error| panic!("write generated migration registration: {error}")); +} + +fn emit_dialect(generated: &mut String, name: &str, dialect: Dialect, inventory: &Inventory) { + generated.push_str("#[cfg(feature = \""); + generated.push_str(dialect.name()); + generated.push_str("\")]\n"); + generated.push_str("pub(crate) const "); + generated.push_str(name); + generated.push_str(": &[EmbeddedMigration] = &[\n"); + for migration in &inventory.migrations { + let file = dialect.file(migration); + generated.push_str(" EmbeddedMigration { version: "); + generated.push_str(&migration.version.to_string()); + generated.push_str(", description: "); + generated.push_str(&format!("{:?}", migration.description)); + generated.push_str(", sql: include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/\", "); + generated.push_str(&format!("{:?}", file.path)); + generated.push_str(")), },\n"); + } + generated.push_str("];\n"); +} + +fn validate_inventory(root: &Path, inventory: &Inventory) { + if inventory.schema_version != INVENTORY_VERSION { + panic!( + "{INVENTORY_PATH} schema version {} is unsupported; expected {INVENTORY_VERSION}", + inventory.schema_version + ); + } + if inventory.migrations.is_empty() || inventory.migrations.len() > MAX_MIGRATIONS { + panic!("{INVENTORY_PATH} must contain 1..={MAX_MIGRATIONS} migrations"); + } + let mut paths = BTreeMap::new(); + for (index, migration) in inventory.migrations.iter().enumerate() { + let expected = (index + 1) as u64; + if migration.version != expected { + panic!( + "{INVENTORY_PATH} versions must be consecutive: expected {expected}, observed {}", + migration.version + ); + } + if migration.version > i64::MAX as u64 + || migration.description.is_empty() + || migration.description.trim() != migration.description + || migration.description.len() > 4 * 1024 + || migration.description.contains('\0') + || is_secret_like(&migration.description) + { + panic!( + "{INVENTORY_PATH} migration {} has an invalid description or version", + migration.version + ); + } + for dialect in Dialect::ALL { + let file = dialect.file(migration); + let display_path = declared_path_display(&file.path); + validate_path(root, dialect, file); + if file.sha256.len() != 64 + || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || file + .sha256 + .chars() + .any(|character| character.is_ascii_uppercase()) + { + panic!( + "{INVENTORY_PATH} {} migration `{}` has an invalid SHA-256", + dialect.name(), + display_path + ); + } + if paths + .insert(file.path.clone(), (migration.version, dialect.name())) + .is_some() + { + panic!( + "{INVENTORY_PATH} migration path `{}` is declared more than once", + display_path + ); + } + let sql_path = root.join(&file.path); + let sql = read_bounded_file(root, &sql_path, MAX_SQL_BYTES, "migration SQL"); + if std::str::from_utf8(&sql).is_err() { + panic!("migration SQL `{display_path}` is not UTF-8"); + } + let observed = sha256_hex(&sql); + if observed != file.sha256 { + panic!( + "{INVENTORY_PATH} {} migration `{}` checksum mismatch: expected {}, observed {}", + dialect.name(), + display_path, + file.sha256, + observed + ); + } + } + } + for dialect in Dialect::ALL { + let actual = collect_sql_files(root, dialect); + for path in actual.keys() { + if !paths.contains_key(path) { + let display_path = declared_path_display(path); + panic!( + "{INVENTORY_PATH} extra {} migration file `{display_path}` is not registered", + dialect.name() + ); + } + } + } + validate_dialect_directories(root); +} + +fn validate_path(root: &Path, dialect: Dialect, file: &MigrationFile) { + let path = Path::new(&file.path); + let display_path = declared_path_display(&file.path); + if file.path.is_empty() + || file.path.trim() != file.path + || file.path.len() > 4 * 1024 + || file.path.contains('\0') + || file.path.contains('\\') + || !file.path.ends_with(".sql") + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !path.starts_with(dialect.directory()) + || is_secret_like(&file.path) + { + panic!( + "{INVENTORY_PATH} {} migration path `{}` is outside `{}`", + dialect.name(), + display_path, + dialect.directory() + ); + } + let mut current = root.to_path_buf(); + for component in path.components() { + let Component::Normal(component) = component else { + unreachable!("validated migration path components"); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t) + .unwrap_or_else(|error| panic!("inspect migration path `{display_path}`: {error}")); + if metadata.file_type().is_symlink() { + panic!("migration path `{display_path}` must not be a symlink"); + } + } +} + +fn declared_path_display(path: &str) -> String { + let path_value = Path::new(path); + if is_secret_like(path) + || path_value.is_absolute() + || path.contains('\\') + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + REDACTED_MIGRATION_PATH.to_string() + } else { + path.to_string() + } +} + +fn is_secret_like(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("postgres://") + || lower.contains("postgresql://") + || lower.contains("mysql://") + || lower.contains("mongodb://") + || lower.contains("bearer ") + || lower.contains("password=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("-----begin ") +} + +fn validate_json_nesting(input: &[u8]) -> Result<(), &'static str> { + let mut depth = 0usize; + let mut escaped = false; + let mut in_string = false; + + for byte in input { + if in_string { + if escaped { + escaped = false; + } else if *byte == b'\\' { + escaped = true; + } else if *byte == b'"' { + in_string = false; + } + continue; + } + + match *byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth = depth.saturating_add(1); + if depth > MAX_JSON_DEPTH { + return Err("migration inventory exceeds maximum JSON nesting depth"); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + + Ok(()) +} + +fn relative_path_display(root: &Path, path: &Path) -> String { + let relative = path + .strip_prefix(root) + .map(|relative| { + relative + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }) + .unwrap_or_else(|_| "".to_string()); + declared_path_display(&relative) +} + +fn read_bounded_file(root: &Path, path: &Path, limit: usize, label: &str) -> Vec { + let relative = relative_path_display(root, path); + let metadata = fs::symlink_metadata(path) + .unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + if metadata.file_type().is_symlink() { + panic!("{label} `{relative}` must not be a symlink"); + } + if !metadata.is_file() { + panic!("{label} `{relative}` is not a regular file"); + } + if metadata.len() > limit as u64 { + panic!("{label} `{relative}` exceeds {limit} bytes"); + } + let file = + File::open(path).unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + let opened_metadata = file + .metadata() + .unwrap_or_else(|error| panic!("inspect opened {label} `{relative}`: {error}")); + if !opened_metadata.is_file() { + panic!("opened {label} `{relative}` is not a regular file"); + } + let opened_size = opened_metadata.len(); + if opened_size > limit as u64 { + panic!("opened {label} `{relative}` exceeds {limit} bytes"); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .unwrap_or_else(|error| panic!("read {label} `{relative}`: {error}")); + if bytes.len() > limit { + panic!("{label} `{relative}` exceeds {limit} bytes"); + } + bytes +} + +fn collect_sql_files(root: &Path, dialect: Dialect) -> BTreeMap { + let mut pending = vec![root.join(dialect.directory())]; + let mut files = BTreeMap::new(); + let mut directories = 0usize; + let mut entries_seen = 0usize; + while let Some(directory) = pending.pop() { + directories += 1; + if directories > MAX_DIRECTORIES { + panic!( + "{} migration directory tree exceeds {MAX_DIRECTORIES} directories", + dialect.name() + ); + } + let directory_metadata = fs::symlink_metadata(&directory).unwrap_or_else(|error| { + panic!( + "inspect migration directory `{}`: {error}", + relative_path_display(root, &directory) + ) + }); + if directory_metadata.file_type().is_symlink() { + panic!( + "migration directory `{}` must not be a symlink", + relative_path_display(root, &directory) + ); + } + if !directory_metadata.is_dir() { + panic!( + "migration directory `{}` is not a directory", + relative_path_display(root, &directory) + ); + } + let mut read_entries = fs::read_dir(&directory).unwrap_or_else(|error| { + panic!( + "read migration directory `{}`: {error}", + relative_path_display(root, &directory) + ) + }); + let remaining_entries = MAX_TOTAL_ENTRIES - entries_seen; + let mut entries = Vec::with_capacity(remaining_entries); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_TOTAL_ENTRIES { + panic!( + "migration directory tree exceeds {MAX_TOTAL_ENTRIES} entries for {}", + dialect.name() + ); + } + entries_seen += 1; + entries.push(entry.unwrap_or_else(|error| { + panic!( + "read migration directory entry for {}: {error}", + dialect.name() + ) + })); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).unwrap_or_else(|error| { + panic!( + "inspect migration path `{}`: {error}", + relative_path_display(root, &path) + ) + }); + if metadata.file_type().is_symlink() { + panic!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ); + } + if metadata.is_dir() { + pending.push(path); + continue; + } + if !metadata.is_file() { + panic!( + "migration path `{}` is not a regular file", + relative_path_display(root, &path) + ); + } + if path.extension().and_then(|extension| extension.to_str()) != Some("sql") { + continue; + } + let relative = path + .strip_prefix(root) + .unwrap_or_else(|_| panic!("migration path escaped repository root")) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + files.insert(relative, ()); + if files.len() > MAX_SQL_FILES { + panic!( + "{} migration directory tree contains more than {MAX_SQL_FILES} SQL files", + dialect.name() + ); + } + } + } + files +} + +fn validate_dialect_directories(root: &Path) { + let migrations = root.join("migrations"); + let migrations_metadata = fs::symlink_metadata(&migrations) + .unwrap_or_else(|error| panic!("inspect migrations directory: {error}")); + if migrations_metadata.file_type().is_symlink() { + panic!("migrations directory must not be a symlink"); + } + if !migrations_metadata.is_dir() { + panic!("migrations path is not a directory"); + } + let mut read_entries = fs::read_dir(&migrations) + .unwrap_or_else(|error| panic!("read migrations directory: {error}")); + let mut entries_seen = 0usize; + let mut entries = Vec::with_capacity(MAX_TOP_LEVEL_ENTRIES); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_TOP_LEVEL_ENTRIES { + panic!("migrations directory exceeds {MAX_TOP_LEVEL_ENTRIES} entries"); + } + entries_seen += 1; + entries.push(entry.unwrap_or_else(|error| panic!("read migrations entry: {error}"))); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("inspect migrations entry: {error}")); + if metadata.file_type().is_symlink() { + panic!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ); + } + if !metadata.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!(name, "sqlite" | "postgres") { + panic!( + "unsupported migration dialect directory `{}`", + relative_path_display(root, &path) + ); + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/distributed.contracts.json b/distributed.contracts.json new file mode 100644 index 00000000..9024e379 --- /dev/null +++ b/distributed.contracts.json @@ -0,0 +1,102 @@ +{ + "schema_version": 1, + "entries": { + "migration-inventory": { + "id": "migration-inventory", + "kind": "migration_inventory", + "scope": { "id": "repository/migrations" }, + "owner": "distributed/migrations", + "identity": { "kind": "migration_inventory", "value": "ref:migrations" }, + "provenance": { + "sources": ["migrations/inventory.json"], + "generator": "distributed.migrations" + }, + "outputs": { "catalog": "distributed.contracts.json" }, + "lifecycle": ["check"] + }, + "surface-e2e-ui": { + "id": "surface-e2e-ui", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui/surface" }, + "owner": "query-layer/e2e-ui/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-user": "tests/e2e-ui/ui/src/lib/generated/user/manifest.json" + }, + "lifecycle": ["check"] + }, + "surface-e2e-ui-admin": { + "id": "surface-e2e-ui-admin", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui-admin/surface" }, + "owner": "query-layer/e2e-ui-admin/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui-admin" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-admin": "tests/e2e-ui/ui/src/lib/generated/admin/manifest.json" + }, + "lifecycle": ["check"] + }, + "surface-e2e-ui-public": { + "id": "surface-e2e-ui-public", + "kind": "surface_client_manifest", + "scope": { "id": "client/e2e-ui-public/surface" }, + "owner": "query-layer/e2e-ui-public/surface", + "identity": { "kind": "surface_client_manifest", "value": "ref:client/e2e-ui-public" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-manifest" + }, + "outputs": { + "surface-manifest-public": "tests/e2e-ui/ui/src/lib/generated/public/manifest.json" + }, + "lifecycle": ["check"] + }, + "generated-e2e-ui": { + "id": "generated-e2e-ui", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui/generated" }, + "owner": "query-layer/e2e-ui/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-user": "tests/e2e-ui/ui/src/lib/generated/user" }, + "lifecycle": ["check"] + }, + "generated-e2e-ui-admin": { + "id": "generated-e2e-ui-admin", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui-admin/generated" }, + "owner": "query-layer/e2e-ui-admin/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui-admin/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-admin": "tests/e2e-ui/ui/src/lib/generated/admin" }, + "lifecycle": ["check"] + }, + "generated-e2e-ui-public": { + "id": "generated-e2e-ui-public", + "kind": "generated_client_tree", + "scope": { "id": "client/e2e-ui-public/generated" }, + "owner": "query-layer/e2e-ui-public/generated", + "identity": { "kind": "generated_client_tree", "value": "ref:client/e2e-ui-public/generated" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client-compiler" + }, + "outputs": { "generated-tree-public": "tests/e2e-ui/ui/src/lib/generated/public" }, + "lifecycle": ["check"] + } + } +} diff --git a/distributed_cli/Cargo.toml b/distributed_cli/Cargo.toml index 5a6f59ea..72130a91 100644 --- a/distributed_cli/Cargo.toml +++ b/distributed_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "distributed_cli" -description = "The `dctl` CLI for Distributed services: scaffold projects, describe their manifest, and render schema artifacts (SQL or Atlas Operator resources). Also a library so other CLIs (e.g. hops) can mount its commands." +description = "The `distributed` CLI for Distributed applications: contracts check/accept, scaffold projects, describe manifests, compile clients, and render schema artifacts. Also a library so other CLIs (e.g. hops) can mount its commands." version.workspace = true edition.workspace = true license.workspace = true @@ -11,7 +11,7 @@ readme = "README.md" path = "src/lib.rs" [[bin]] -name = "dctl" +name = "distributed" path = "src/main.rs" [dependencies] diff --git a/distributed_cli/README.md b/distributed_cli/README.md index 4567fd7d..d1546711 100644 --- a/distributed_cli/README.md +++ b/distributed_cli/README.md @@ -1,24 +1,24 @@ -# distributed_cli (`dctl`) +# distributed_cli (`distributed`) -Service tooling for [Distributed](https://crates.io/crates/distributed): a `dctl` +Service tooling for [Distributed](https://crates.io/crates/distributed): a `distributed` binary — and a library — that scaffolds service crates, inspects a service's -project manifest, and renders schema artifacts (SQL or an Atlas Operator -resource). +logical ApplicationManifest, and renders physical read-model schema artifacts +(SQL or an Atlas Operator resource). ```bash -cargo install distributed_cli # installs the `dctl` binary +cargo install distributed_cli # installs the `distributed` binary ``` It is also a library, so another CLI can mount its commands instead of reimplementing them. `hops`, for example, exposes the same surface under `hops service` by depending on this crate and dispatching with -`distributed_cli::run`. **Everything below documented as `dctl ` is also +`distributed_cli::run`. **Everything below documented as `distributed ` is also available as `hops service `.** -## `dctl scaffold ` — generate a service crate +## `distributed scaffold ` — generate a service crate ```bash -dctl scaffold orders --store postgres --transport http --gitops +distributed scaffold orders --store postgres --transport http --gitops ``` Writes a ready-to-build Distributed service under `./` (override with @@ -26,7 +26,7 @@ Writes a ready-to-build Distributed service under `./` (override with `, `--model ` (repeatable), `--read-models`, `--command` / `--event` (repeatable), `--bus `, `--gitops`, `--metrics prometheus`, `--tracing` / `--otel`, `--gitops-promote `, -`--github OWNER/REPO`, `--force`. See `dctl scaffold --help` for the full list. +`--github OWNER/REPO`, `--force`. See `distributed scaffold --help` for the full list. When used with `--gitops`, `--metrics prometheus` emits Prometheus Operator `ServiceMonitor` and `PrometheusRule` templates for HTTP services. The @@ -38,11 +38,11 @@ emit `monitoring.coreos.com` resources. OTLP tracing setup in the generated `main.rs`, and renders OTLP environment values in the Helm chart without hard-coding an endpoint. -## `dctl skills init` — extract agent skills into a project +## `distributed skills init` — extract agent skills into a project ```bash -dctl skills init # writes ./.distributed/skills/ and wires harnesses -dctl skills list # names + descriptions of the embedded skills +distributed skills init # writes ./.distributed/skills/ and wires harnesses +distributed skills list # names + descriptions of the embedded skills ``` Materializes the **agent skills** embedded in the binary — markdown guidance @@ -74,14 +74,15 @@ directories are never touched. After a CLI upgrade, re-run with `--force` to refresh existing skill files to the binary's embedded content; without `--force`, differing files are treated as local edits and skipped. -## The project manifest entrypoint +## The artifact entrypoints -`describe` and `schema` work by compiling your service crate and calling an -exported manifest function — by default `::distributed_manifest`. Add one -to your service that registers its read models / tables and services: +`describe` compiles your service crate and calls the explicit logical +application-manifest entrypoint — by default `::application_manifest`. +`schema` calls the separate physical read-model catalog entrypoint — by default +`::read_model_catalog`. Keep those owners separate: ```rust -use distributed::{DistributedProjectManifest, ReadModel}; +use distributed::{ReadModelCatalog, ReadModel}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] @@ -92,8 +93,8 @@ pub struct OrderView { pub status: String, } -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } ``` @@ -102,10 +103,10 @@ compile the target crate, they need the local `distributed` crate to be resolvable — found automatically from the workspace, or pass `--distributed-path` / set `DISTRIBUTED_PATH`. -## `dctl client-manifest` — authorized client surface +## `distributed client-manifest` — authorized client surface ```bash -dctl client-manifest > target/distributed-client.json +distributed client-manifest > target/distributed-client.json ``` Compiles the service's `distributed_client_surface` export into the versioned, @@ -113,17 +114,17 @@ role/application-selected manifest used by the operation compiler. The export already contains one concrete role or named application surface; it is not an admin catalog that downstream tools filter themselves. -## `dctl client` — typed query, live, and command artifacts +## `distributed client` — typed query, live, and command artifacts ```bash -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ --out src/generated/distributed # CI: parse, validate, and compare without writing -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ @@ -158,17 +159,17 @@ co-located document with a different filename can use the explicit fallback build time with their source location; the compiler does not emit a partial normalization plan. -## `dctl describe` — manifest as JSON +## `distributed describe` — manifest as JSON ```bash -dctl describe # current directory -dctl describe --manifest-path path/to/Cargo.toml --package orders-service +distributed describe # current directory +distributed describe --manifest-path path/to/Cargo.toml --package orders-service ``` Prints the versioned manifest envelope (schemas, services, transports) as JSON — a stable contract for other tooling. -## `dctl schema` — schema artifacts +## `distributed schema` — schema artifacts Renders the **desired-state** schema for the manifest's read models and operational tables. Output goes to stdout by default (or `--out `). @@ -176,7 +177,7 @@ operational tables. Output goes to stdout by default (or `--out `). ### SQL (default) ```bash -dctl schema --dialect postgres # or --dialect sqlite +distributed schema --dialect postgres # or --dialect sqlite ``` ### Atlas Operator resource (`--format atlas`) @@ -185,12 +186,12 @@ Wraps the desired-state SQL into an `AtlasSchema` (`db.atlasgo.io/v1alpha1`) for the [ariga atlas-operator](https://github.com/ariga/atlas-operator), so the operator diffs the live database against it and applies the migration in-cluster. -The resource is written to **stdout** — `dctl` deliberately does not pick a +The resource is written to **stdout** — `distributed` deliberately does not pick a location for it. Redirect it wherever you keep schema manifests: a file in the service repo, or a separate GitOps/schema repo. ```bash -dctl schema --format atlas \ +distributed schema --format atlas \ --name orders \ --namespace data \ --db-secret orders-db \ diff --git a/distributed_cli/skills/distributed-ci/SKILL.md b/distributed_cli/skills/distributed-ci/SKILL.md index e6e98a0c..484c6b64 100644 --- a/distributed_cli/skills/distributed-ci/SKILL.md +++ b/distributed_cli/skills/distributed-ci/SKILL.md @@ -1,11 +1,11 @@ --- name: distributed-ci -description: Set up CI, release workflows, and GitOps promotion for a Distributed service with dctl scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation. +description: Set up CI, release workflows, and GitOps promotion for a Distributed service with distributed scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation. --- # CI and GitOps for Distributed services -`dctl scaffold` generates the whole delivery pipeline — Helm deploy chart, +`distributed scaffold` generates the whole delivery pipeline — Helm deploy chart, GitHub Actions workflows, and promotion charts — from flags. Prefer regenerating/extending these artifacts over hand-writing pipeline YAML. @@ -74,7 +74,7 @@ set, because the promotion charts target `.gitops/deploy`. ## Gotchas -- `dctl scaffold` **refuses a non-empty directory** without `--force`; adding +- `distributed scaffold` **refuses a non-empty directory** without `--force`; adding CI to an existing service means running scaffold with `--force` in a clean worktree and reviewing the diff, or copying the generated workflows in. - The three GitHub repos gate independent slices — you can adopt @@ -93,5 +93,5 @@ set, because the promotion charts target `.gitops/deploy`. ## Reference -Full flag list: `dctl scaffold --help`. The same commands ship as +Full flag list: `distributed scaffold --help`. The same commands ship as `hops service scaffold ...` when using the `hops` CLI. diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index 2e2ee0a4..c805d707 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -25,7 +25,7 @@ second GraphQL command registry. ### Add a model exposure -1. Ensure the read model is in `distributed_manifest()`. +1. Ensure the read model is in `read_model_catalog()`. 2. Add `src/query/.rs`: ```rust @@ -170,7 +170,7 @@ client manifests). ## SDL artifact (CI gate) ```bash -dctl schema --format graphql --out schema.graphql +distributed schema --format graphql --out schema.graphql git diff --exit-code schema.graphql ``` @@ -179,7 +179,7 @@ git diff --exit-code schema.graphql ## Scaffold ```bash -dctl scaffold my-service --query-api --read-models --store sqlite +distributed scaffold my-service --query-api --read-models --store sqlite ``` Emits typed causal handlers, a service-derived GraphQL engine, `src/query/` diff --git a/distributed_cli/skills/distributed-schema/SKILL.md b/distributed_cli/skills/distributed-schema/SKILL.md index e6f32265..0ea2d56b 100644 --- a/distributed_cli/skills/distributed-schema/SKILL.md +++ b/distributed_cli/skills/distributed-schema/SKILL.md @@ -1,21 +1,22 @@ --- name: distributed-schema -description: Inspect a Distributed service manifest and render schema artifacts - dctl describe (manifest JSON), dctl schema (migration SQL or an Atlas Operator resource), and the distributed_manifest() envelope contract. Use when working on read-model schemas, migrations, or schema automation. +description: Inspect a Distributed read-model catalog and render schema artifacts - distributed describe (application JSON), distributed schema (migration SQL or an Atlas Operator resource), and the explicit application/read-model artifact contract. Use when working on read-model schemas, migrations, or schema automation. --- # Manifests and schema artifacts -`dctl describe` and `dctl schema` are the schema toolchain for a Distributed -service. Both **compile the target crate** and call its exported manifest -function — by default `::distributed_manifest` (override with -`--entrypoint `). +`distributed describe` and `distributed schema` are the artifact toolchain for a Distributed +service. Both **compile the target crate**, but they call different explicit +owners: `describe` defaults to `::application_manifest` for the logical +application artifact, while `schema` defaults to `::read_model_catalog` +for physical read-model SQL/SDL (override with `--entrypoint `). ## The manifest entrypoint The service must export a function that registers its read models / tables: ```rust -use distributed::{DistributedProjectManifest, ReadModel}; +use distributed::{ReadModelCatalog, ReadModel}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] #[table("orders")] @@ -25,33 +26,34 @@ pub struct OrderView { pub status: String, } -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } ``` -A read model not registered here is invisible to `describe`/`schema` — no SQL -is rendered for it. When you add a `#[derive(ReadModel)]` type, register it. +A read model not registered here is invisible to `schema` — no SQL is rendered +for it. When you add a `#[derive(ReadModel)]` type, register it; the logical +application entrypoint must select its own Surface contract explicitly. -## `dctl describe` — manifest JSON +## `distributed describe` — manifest JSON ```bash -dctl describe # current directory -dctl describe --manifest-path path/to/Cargo.toml --package orders-service +distributed describe # current directory +distributed describe --manifest-path path/to/Cargo.toml --package orders-service ``` Prints the versioned manifest envelope. The contract other tooling relies on: -a numeric `schema_version` (currently `1`) and a `project` object. `dctl` +a numeric `schema_version` (currently `1`) and a `project` object. `distributed` rejects envelopes with a missing/different `schema_version`, so treat the envelope as a stable machine interface, not free-form JSON. -## `dctl schema` — SQL or Atlas resource +## `distributed schema` — SQL or Atlas resource Renders the **desired-state** schema for the manifest's read models and operational tables. Output goes to stdout (or `--out `). ```bash -dctl schema --dialect postgres # migration SQL (or --dialect sqlite) +distributed schema --dialect postgres # migration SQL (or --dialect sqlite) ``` ### Atlas Operator resource @@ -61,7 +63,7 @@ for the ariga atlas-operator, which diffs the live database and applies the migration in-cluster — declarative schema via GitOps: ```bash -dctl schema --format atlas \ +distributed schema --format atlas \ --name orders \ --namespace data \ --db-secret orders-db \ @@ -78,7 +80,7 @@ dctl schema --format atlas \ - `--dev-url` sets `spec.devURL`, the scratch database Atlas uses to plan changes. - The resource goes to **stdout** deliberately — redirect it to wherever schema - manifests live (service repo or a GitOps repo). `dctl` does not pick a + manifests live (service repo or a GitOps repo). `distributed` does not pick a location. ## Running in CI @@ -95,14 +97,14 @@ dependencies resolvable: Typical schema-gate step: render and diff so schema drift fails the build. ```bash -dctl schema --dialect postgres --out rendered.sql +distributed schema --dialect postgres --out rendered.sql git diff --exit-code rendered.sql ``` Or regenerate the Atlas resource and let the GitOps PR carry the change: ```bash -dctl schema --format atlas --name orders --db-secret orders-db \ +distributed schema --format atlas --name orders --db-secret orders-db \ --out manifests/orders.schema.yaml ``` @@ -115,7 +117,7 @@ dctl schema --format atlas --name orders --db-secret orders-db \ `#[readmodel(table = "...", primary_key = ["a", "b"])]`, `#[index(...)]`. - Rendered SQL is desired-state, not a diff. Diffing against the live database is the Atlas operator's job (`--format atlas`) or your migration tool's. -- `dctl schema` and `dctl describe` also ship as `hops service schema` / +- `distributed schema` and `distributed describe` also ship as `hops service schema` / `hops service describe`. ## Reference diff --git a/distributed_cli/skills/distributed-usage/SKILL.md b/distributed_cli/skills/distributed-usage/SKILL.md index 90acc169..f76d85c4 100644 --- a/distributed_cli/skills/distributed-usage/SKILL.md +++ b/distributed_cli/skills/distributed-usage/SKILL.md @@ -1,6 +1,6 @@ --- name: distributed-usage -description: Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and dctl generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model. +description: Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and distributed generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model. --- # Using the Distributed framework @@ -11,7 +11,7 @@ description: Build Distributed CQRS/event-sourced Rust services where you mostly first, then write thin handlers around proven behavior.** Everything else — service wiring, transports, persistence, manifests, schema, -CI/GitOps — is deterministic structure the framework, macros, and `dctl` +CI/GitOps — is deterministic structure the framework, macros, and `distributed` generate. Your authored surface is deliberately small: aggregate models (`#[sourced]` event methods), command/event handler bodies, and read-model shapes. If you find yourself hand-writing service plumbing, routing, broker @@ -49,7 +49,7 @@ traits — production swaps are one constructor line, never a handler change. ## Workflow 1. Scaffold a service instead of hand-rolling layout: - `dctl scaffold --model --command --event --store postgres --transport http --bus nats --gitops` + `distributed scaffold --model --command --event --store postgres --transport http --bus nats --gitops` (from an event-storming board: aggregates → `--model`, commands → `--command`, events/policies → `--event`, query views → `--read-models`). 2. Write failing, colocated unit tests against the aggregate command API you @@ -171,7 +171,7 @@ of exposing a raw `Context`/`serde_json::Value` handler as the mutation contract - bind that exact `Service` through `GraphqlEngineBuilder::service`, configure public OIDC, and call `.without_http_command_routes()` so browser writes use only the GraphQL command proxy; -- generate the strictly typed client with `dctl client`. +- generate the strictly typed client with `distributed client`. Use the `distributed-graphql` skill for the complete route, consistency, authorization, and client-generation contract. The raw handler form below @@ -285,7 +285,7 @@ Copy the **e2e-ui** fixture under `tests/e2e-ui/` (README; see `tests/e2e-ui/REA crates/ todo-domain/ # personal todos (owner-scoped) chat-domain/ # lobby chat (shared room) - readmodels/ # projections + distributed_manifest + readmodels/ # projections + read_model_catalog service/ # thin command handlers + event projectors + GraphQL runner/ # store + bus + bind suite/ # HTTP/GraphQL behavioral cases @@ -302,7 +302,7 @@ Rules the fixture demonstrates: - GraphQL row filter: `owner_id = claim(x-user-id)` for role `user` - **Typed GraphQL commands** (`Eventual` / `Atomic`) via the OIDC command proxy; generic direct command POST routes are disabled -- **Generated client**: `dctl client` produces the typed replica/query/command +- **Generated client**: `distributed client` produces the typed replica/query/command artifacts consumed by the SvelteKit app - **Subscriptions**: wire `SqliteRepository::read_model_changes()` into `GraphqlEngineBuilder::change_stream`; clients use WebSocket `/graphql/ws` @@ -311,12 +311,12 @@ Run the full app: `cd tests/e2e-ui && make`. Suite: `make test`. ## Manifest entrypoint -Every service should export `distributed_manifest()` registering its read -models — `dctl describe` and `dctl schema` compile the crate and call it: +Every service should export `read_model_catalog()` registering its read +models — `distributed describe` and `distributed schema` compile the crate and call it: ```rust -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { - distributed::DistributedProjectManifest::new("todos").read_model::() +pub fn read_model_catalog() -> distributed::ReadModelCatalog { + distributed::ReadModelCatalog::new("todos").read_model::() } ``` diff --git a/distributed_cli/src/atlas.rs b/distributed_cli/src/atlas.rs index d90ba34d..8314a74e 100644 --- a/distributed_cli/src/atlas.rs +++ b/distributed_cli/src/atlas.rs @@ -4,7 +4,7 @@ //! it wants (e.g. stdout → any file, or a separate schema repo) — this crate //! intentionally does **not** decide a `.gitops/` location for it. //! -//! The desired-state SQL (e.g. `DistributedProjectManifest::sql_statements`) goes +//! The desired-state SQL (e.g. `ReadModelCatalog::sql_statements`) goes //! into `spec.schema.sql`; the operator diffs the live database against it and //! applies the change. //! diff --git a/distributed_cli/src/cli.rs b/distributed_cli/src/cli.rs index 47d88d6d..6735a65f 100644 --- a/distributed_cli/src/cli.rs +++ b/distributed_cli/src/cli.rs @@ -1,11 +1,11 @@ -//! The `dctl` command surface: clap types plus the [`run`] dispatcher. Generation -//! lives in the crate's `generate`/`atlas` modules and the `describe`/`schema` -//! harness in `manifest_harness`; this module maps flags onto those types and -//! owns the scaffold's filesystem / process side effects (writing files, -//! running `gh`). +//! The `distributed` command surface: clap types plus the [`run_distributed`] +//! dispatcher. Generation lives in the crate's `generate`/`atlas` modules and +//! the `describe`/`schema` harness in `manifest_harness`; this module maps +//! flags onto those types and owns filesystem / process side effects. //! -//! `hops` mounts [`ServiceArgs`] under `hops service` and dispatches with [`run`], -//! re-exporting the commands rather than reimplementing them. +//! Host CLIs (for example `hops`) may mount [`ServiceArgs`] under a nested +//! service command and dispatch with [`run`], re-exporting rather than +//! reimplementing service-related commands. use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use std::collections::{BTreeMap, BTreeSet}; @@ -19,6 +19,10 @@ use crate::client_compiler::{ compile_client, ClientCompileInput, ClientDocument, ClientRouteRegistration, ClientSurfaceSelector, GeneratedClientFile, GeneratedClientProject, }; +use crate::contracts::{ + contracts_accept, contracts_check, unknown_scope_diagnostic, ContractAcceptScope, + ContractCatalog, +}; use crate::manifest_harness::{run_manifest_harness, HarnessMode, HarnessOptions}; use crate::skills::{embedded_skills, generate_skills, SkillsInitSpec, AGENTS_MD_FILE}; use crate::{ @@ -30,6 +34,33 @@ use crate::{ const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u64 = 1; const DISTRIBUTED_CLIENT_MANIFEST_VERSION: u64 = 2; +/// Top-level standalone CLI arguments for the `distributed` binary. +#[derive(Args, Debug)] +pub struct DistributedArgs { + #[command(subcommand)] + pub command: DistributedCommands, +} + +#[derive(Subcommand, Debug)] +pub enum DistributedCommands { + /// Aggregate contract lifecycle check and accept + Contracts(ContractsArgs), + /// Scaffold a new Distributed microservice crate + #[command(alias = "create")] + Scaffold(ScaffoldArgs), + /// Print a service's explicit ApplicationManifest as JSON + Describe(DescribeArgs), + /// Compile role/application-scoped GraphQL operations into client artifacts + Client(ClientArgs), + /// Compile the service's authorized client Surface manifest as JSON + ClientManifest(ClientManifestArgs), + /// Render schema artifacts (SQL or an Atlas Operator resource) from a read-model catalog + Schema(SchemaArgs), + /// Extract the embedded Distributed agent skills into a project + Skills(SkillsArgs), +} + +/// Library adapter for embedding service-related commands under another CLI. #[derive(Args, Debug)] pub struct ServiceArgs { #[command(subcommand)] @@ -41,18 +72,67 @@ pub enum ServiceCommands { /// Scaffold a new Distributed microservice crate #[command(alias = "create")] Scaffold(ScaffoldArgs), - /// Print a service's Distributed project manifest as JSON + /// Print a service's explicit ApplicationManifest as JSON Describe(DescribeArgs), /// Compile role/application-scoped GraphQL operations into client artifacts Client(ClientArgs), /// Compile the service's authorized client Surface manifest as JSON ClientManifest(ClientManifestArgs), - /// Render schema artifacts (SQL or an Atlas Operator resource) from a manifest + /// Render schema artifacts (SQL or an Atlas Operator resource) from a read-model catalog Schema(SchemaArgs), /// Extract the embedded Distributed agent skills into a project Skills(SkillsArgs), } +#[derive(Args, Debug)] +pub struct ContractsArgs { + #[command(subcommand)] + pub command: ContractsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum ContractsCommands { + /// Read-only aggregate contract check (never writes tracked files) + Check(ContractsCheckArgs), + /// Exact-scope accept with staging, atomic replace, and rollback + Accept(ContractsAcceptArgs), +} + +#[derive(Args, Debug)] +pub struct ContractsCheckArgs { + /// Catalog root directory + #[arg(long, default_value = ".")] + pub root: PathBuf, + /// Path to the contract catalog JSON (relative to root or absolute) + #[arg(long, default_value = "contracts/catalog.json")] + pub catalog: PathBuf, + /// Output format + #[arg(long, value_enum, default_value = "human")] + pub output: ContractsOutput, +} + +#[derive(Args, Debug)] +pub struct ContractsAcceptArgs { + /// Catalog root directory + #[arg(long, default_value = ".")] + pub root: PathBuf, + /// Exact accept scope (no broad wildcards) + #[arg(long)] + pub scope: String, + /// Staged payload file: JSON object mapping portable relative paths to UTF-8 contents + #[arg(long)] + pub staged: PathBuf, + /// Output format + #[arg(long, value_enum, default_value = "human")] + pub output: ContractsOutput, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum ContractsOutput { + Human, + Json, +} + #[derive(Args, Debug)] pub struct SkillsArgs { #[command(subcommand)] @@ -130,7 +210,7 @@ pub struct ScaffoldArgs { /// Model aggregate to scaffold. May be repeated. #[arg(long)] pub model: Vec, - /// Generate placeholder read-model modules and register them in distributed_manifest(). + /// Generate placeholder read-model modules and register them in read_model_catalog(). #[arg(long)] pub read_models: bool, /// Generate src/query/ GraphQL skeleton, enable the graphql feature, and wire with_graphql. @@ -201,7 +281,7 @@ pub struct DescribeArgs { /// Disable default features on the target service dependency. #[arg(long)] pub no_default_features: bool, - /// Manifest function to call. Defaults to ::distributed_manifest. + /// Application manifest function to call. Defaults to ::application_manifest. #[arg(long)] pub entrypoint: Option, /// Output format. @@ -255,6 +335,12 @@ pub struct ClientArgs { /// Verify that the manifest is selected for this named application surface. #[arg(long)] pub surface: Option, + /// Explicit eligible application roles (repeat for each role). + #[arg(long, value_name = "ROLE", requires = "surface")] + pub eligible_role: Vec, + /// Explicit schema application roles (repeat for each role). + #[arg(long, value_name = "ROLE", requires = "surface")] + pub schema_role: Vec, /// GraphQL document glob. Repeat for multiple source roots. #[arg(long, required = true, value_name = "GLOB")] pub documents: Vec, @@ -286,7 +372,7 @@ pub struct SchemaArgs { /// Disable default features on the target service dependency. #[arg(long)] pub no_default_features: bool, - /// Manifest function to call. Defaults to ::distributed_manifest. + /// Read-model catalog function to call. Defaults to ::read_model_catalog. #[arg(long)] pub entrypoint: Option, /// SQL dialect to render. @@ -430,8 +516,24 @@ impl From for GitopsPromoteTarget { } } -/// Dispatch a parsed service command. The `dctl` binary and any host CLI (e.g. -/// `hops service`) both call this. +/// Dispatch the standalone `distributed` binary command tree. +pub fn run_distributed(args: &DistributedArgs) -> Result<(), Box> { + match &args.command { + DistributedCommands::Contracts(contracts) => run_contracts(contracts), + DistributedCommands::Scaffold(scaffold) => run_scaffold(scaffold), + DistributedCommands::Describe(describe) => run_describe(describe), + DistributedCommands::Client(client) => run_client(client), + DistributedCommands::ClientManifest(client) => run_client_manifest(client), + DistributedCommands::Schema(schema) => run_schema(schema), + DistributedCommands::Skills(skills) => match &skills.command { + SkillsCommands::Init(init) => run_skills_init(init), + SkillsCommands::List => run_skills_list(), + }, + } +} + +/// Dispatch a parsed service command. Host CLIs (for example `hops service`) +/// call this without mounting the aggregate contracts surface. pub fn run(args: &ServiceArgs) -> Result<(), Box> { match &args.command { ServiceCommands::Scaffold(scaffold) => run_scaffold(scaffold), @@ -446,6 +548,86 @@ pub fn run(args: &ServiceArgs) -> Result<(), Box> { } } +fn run_contracts(args: &ContractsArgs) -> Result<(), Box> { + match &args.command { + ContractsCommands::Check(check) => run_contracts_check(check), + ContractsCommands::Accept(accept) => run_contracts_accept(accept), + } +} + +fn run_contracts_check(args: &ContractsCheckArgs) -> Result<(), Box> { + let root = absolute_path(&args.root)?; + let catalog_path = if args.catalog.is_absolute() { + args.catalog.clone() + } else { + root.join(&args.catalog) + }; + let catalog = ContractCatalog::from_path(&catalog_path)?; + let report = contracts_check(&catalog, &root, std::iter::empty()); + match args.output { + ContractsOutput::Human => { + if report.human.is_empty() { + println!("contracts check: ok"); + } else { + println!("{}", report.human); + } + } + ContractsOutput::Json => { + println!("{}", serde_json::to_string_pretty(&report.result)?); + } + } + if report.ok { + Ok(()) + } else { + Err("contracts check failed".into()) + } +} + +fn run_contracts_accept(args: &ContractsAcceptArgs) -> Result<(), Box> { + let Some(scope) = ContractAcceptScope::parse(&args.scope) else { + let diagnostic = unknown_scope_diagnostic(&args.scope); + return Err(diagnostic.human().into()); + }; + let root = absolute_path(&args.root)?; + let staged_source = fs::read_to_string(&args.staged)?; + let staged_json: serde_json::Value = serde_json::from_str(&staged_source)?; + let object = staged_json + .as_object() + .ok_or("staged payload must be a JSON object of path -> string contents")?; + let mut staged = BTreeMap::new(); + for (path, value) in object { + let contents = value + .as_str() + .ok_or_else(|| format!("staged path `{path}` must map to a UTF-8 string"))?; + staged.insert(path.clone(), contents.as_bytes().to_vec()); + } + let report = contracts_accept(&root, scope, &staged)?; + match args.output { + ContractsOutput::Human => { + if report.noop { + println!("contracts accept: no-op ({})", report.scope); + } else { + println!( + "contracts accept: updated {} path(s) for scope {}", + report.changed_paths.len(), + report.scope + ); + for path in &report.changed_paths { + println!(" {path}"); + } + } + } + ContractsOutput::Json => { + println!("{}", serde_json::to_string_pretty(&report)?); + } + } + if report.ok { + Ok(()) + } else { + Err("contracts accept failed".into()) + } +} + fn run_skills_list() -> Result<(), Box> { let width = embedded_skills() .iter() @@ -796,7 +978,23 @@ fn run_client(args: &ClientArgs) -> Result<(), Box> { })?; let selector = match (&args.role, &args.surface) { (Some(role), None) => ClientSurfaceSelector::role(role.clone()), - (None, Some(surface)) => ClientSurfaceSelector::application(surface.clone()), + (None, Some(surface)) => { + // Prefer explicit CLI roles when both lists are provided; otherwise + // take eligible/schema roles from the application surface in the + // manifest (one source of truth — no dual inventory config). + let (eligible_roles, schema_roles) = + if !args.eligible_role.is_empty() && !args.schema_role.is_empty() { + (args.eligible_role.clone(), args.schema_role.clone()) + } else if !args.eligible_role.is_empty() || !args.schema_role.is_empty() { + return Err( + "pass both --eligible-role and --schema-role, or neither (to use the manifest surface)" + .into(), + ); + } else { + application_roles_from_manifest(&manifest, surface)? + }; + ClientSurfaceSelector::application(surface.clone(), eligible_roles, schema_roles) + } _ => { return Err("pass exactly one of --role or --surface ".into()); } @@ -843,6 +1041,55 @@ fn read_utf8_bounded(path: &Path, limit: usize, label: &str) -> Result Result<(Vec, Vec), Box> { + let Some(surface) = manifest.get("surface") else { + return Ok((Vec::new(), Vec::new())); + }; + let kind = surface + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = surface + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if kind != "application" || name != surface_name { + return Ok((Vec::new(), Vec::new())); + } + let roles = |field: &str| -> Result, Box> { + let values = surface + .get(field) + .and_then(|value| value.as_array()) + .ok_or_else(|| format!("client manifest surface is missing non-empty `{field}`"))?; + let roles = values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("client manifest surface `{field}` must be strings")) + }) + .collect::, _>>()?; + if roles.is_empty() { + return Err(format!("client manifest surface `{field}` must not be empty").into()); + } + Ok(roles) + }; + Ok((roles("eligible_roles")?, roles("schema_roles")?)) +} + fn collect_client_documents(patterns: &[String]) -> Result, Box> { let project_root = fs::canonicalize(std::env::current_dir()?)?; let mut matched = BTreeMap::::new(); @@ -1187,7 +1434,7 @@ fn stale_generated_client_files( MAX_GENERATED_CLIENT_ARTIFACT_BYTES, "stale generated client artifact", )?; - if !contents.starts_with("/** GENERATED by dctl client. Do not edit. */") { + if !contents.starts_with("/** GENERATED by distributed client. Do not edit. */") { return Err(format!( "refusing to remove {} because its compiler ownership marker is missing", path.display() @@ -1251,7 +1498,7 @@ fn check_client_project( } drift.sort(); Err(format!( - "generated Distributed client artifacts are stale:\n {}\nrun `dctl client` without --check to regenerate", + "generated Distributed client artifacts are stale:\n {}\nrun `distributed client` without --check to regenerate", drift.join("\n ") ) .into()) @@ -1366,7 +1613,7 @@ fn run_schema(args: &SchemaArgs) -> Result<(), Box> { let msg = err.to_string(); if msg.contains("graphql_sdl") || msg.contains("no method named `graphql_sdl`") { format!( - "target service's distributed version predates graphql schema support — upgrade distributed to a version that provides DistributedProjectManifest::graphql_sdl(): {msg}" + "target service's distributed version predates read-model GraphQL schema support — upgrade distributed to a version that provides graphql_sdl_for_tables(): {msg}" ).into() } else { err @@ -1583,8 +1830,24 @@ fn validate_manifest_json(envelope: &serde_json::Value) -> Result<(), Box { validate_nonempty(name, "manifest.surface.name")?; } - ManifestSurface::Application { name, roles } => { + ManifestSurface::Application { + name, + eligible_roles, + schema_roles, + } => { validate_nonempty(name, "manifest.surface.name")?; - if roles.is_empty() { + if eligible_roles.is_empty() { return Err(ClientCompileError::manifest( - "client.manifest.surface_roles", + "client.manifest.surface_eligible_roles", format!("application surface `{name}` must declare at least one role"), )); } - canonicalize_string_set(roles, &format!("application surface `{name}` role"))?; + canonicalize_string_set( + eligible_roles, + &format!("application surface `{name}` eligible role"), + )?; + if schema_roles.is_empty() { + return Err(ClientCompileError::manifest( + "client.manifest.surface_schema_roles", + format!("application surface `{name}` must declare at least one schema role"), + )); + } + canonicalize_string_set( + schema_roles, + &format!("application surface `{name}` schema role"), + )?; + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { + return Err(ClientCompileError::manifest( + "client.manifest.surface_schema_roles", + format!( + "application surface `{name}` schema roles must be a subset of eligible roles" + ), + )); + } } } Ok(()) @@ -120,14 +148,26 @@ pub(crate) fn validate_surface( ( ManifestSurface::Application { name: actual, - roles, + eligible_roles, + schema_roles, + }, + ClientSurfaceSelector::Application { + name: expected, + eligible_roles: expected_eligible_roles, + schema_roles: expected_schema_roles, }, - ClientSurfaceSelector::Application { name: expected }, ) => { !expected.trim().is_empty() && actual == expected - && !roles.is_empty() - && roles.iter().all(|role| !role.trim().is_empty()) + && eligible_roles == expected_eligible_roles + && schema_roles == expected_schema_roles + && !eligible_roles.is_empty() + && eligible_roles.iter().all(|role| !role.trim().is_empty()) + && !schema_roles.is_empty() + && schema_roles.iter().all(|role| !role.trim().is_empty()) + && schema_roles + .iter() + .all(|role| eligible_roles.iter().any(|eligible| eligible == role)) } _ => false, }; @@ -140,7 +180,15 @@ pub(crate) fn validate_surface( }; let expected_label = match expected { ClientSurfaceSelector::Role { name } => format!("role `{name}`"), - ClientSurfaceSelector::Application { name } => format!("application `{name}`"), + ClientSurfaceSelector::Application { + name, + eligible_roles, + schema_roles, + } => format!( + "application `{name}` (eligible roles [{}], schema roles [{}])", + eligible_roles.join(", "), + schema_roles.join(", ") + ), }; Err(ClientCompileError::manifest( "client.manifest.surface_mismatch", @@ -197,7 +245,7 @@ pub(crate) fn validate_execution_limits( return Err(ClientCompileError::manifest( "client.manifest.complexity_version", format!( - "unsupported query complexity contract version {}; dctl requires version 1", + "unsupported query complexity contract version {}; distributed requires version 1", execution.complexity.version ), )); diff --git a/distributed_cli/src/client_compiler/manifest/parse.rs b/distributed_cli/src/client_compiler/manifest/parse.rs index e332a568..c9651b27 100644 --- a/distributed_cli/src/client_compiler/manifest/parse.rs +++ b/distributed_cli/src/client_compiler/manifest/parse.rs @@ -86,7 +86,7 @@ impl ClientManifest { return Err(ClientCompileError::manifest( "client.manifest.protocol_fingerprint", format!( - "client compiler protocol contract is `{PROTOCOL_FINGERPRINT}`, received `{}`; regenerate the manifest and use a matching dctl version", + "client compiler protocol contract is `{PROTOCOL_FINGERPRINT}`, received `{}`; regenerate the manifest and use a matching distributed version", wire.protocol_fingerprint ), )); diff --git a/distributed_cli/src/client_compiler/manifest/types.rs b/distributed_cli/src/client_compiler/manifest/types.rs index 85f632a8..2ca442ae 100644 --- a/distributed_cli/src/client_compiler/manifest/types.rs +++ b/distributed_cli/src/client_compiler/manifest/types.rs @@ -55,7 +55,11 @@ pub(crate) struct ManifestComplexityWeights { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum ManifestSurface { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1070,9 +1074,30 @@ pub(crate) struct ManifestCommandProjection { pub(crate) event_set: Vec, pub(crate) program_arms: Vec, pub(crate) preview_occurrences: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) pure_reduces: Vec, pub(crate) fallback: ManifestProjectionFallback, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManifestCommandPureReduce { + pub(crate) fn_name: String, + pub(crate) client_module: String, + pub(crate) client_export: String, + pub(crate) model: String, + pub(crate) key: Vec, + pub(crate) args: Vec, + pub(crate) assign: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManifestCommandPureArg { + pub(crate) name: String, + pub(crate) source: ManifestProjectionPreviewSource, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ManifestProjectionFallback { diff --git a/distributed_cli/src/client_compiler/mod.rs b/distributed_cli/src/client_compiler/mod.rs index 9e101806..74180618 100644 --- a/distributed_cli/src/client_compiler/mod.rs +++ b/distributed_cli/src/client_compiler/mod.rs @@ -66,7 +66,11 @@ impl ClientCompileInput { #[serde(tag = "kind", rename_all = "snake_case")] pub enum ClientSurfaceSelector { Role { name: String }, - Application { name: String }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ClientSurfaceSelector { @@ -74,8 +78,22 @@ impl ClientSurfaceSelector { Self::Role { name: name.into() } } - pub fn application(name: impl Into) -> Self { - Self::Application { name: name.into() } + pub fn application( + name: impl Into, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, + ) -> Self { + let mut eligible_roles = eligible_roles.into_iter().map(Into::into).collect::>(); + let mut schema_roles = schema_roles.into_iter().map(Into::into).collect::>(); + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + Self::Application { + name: name.into(), + eligible_roles, + schema_roles, + } } } diff --git a/distributed_cli/src/client_compiler/projection_delta/preview.rs b/distributed_cli/src/client_compiler/projection_delta/preview.rs index 642aa085..e199389e 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -79,17 +79,51 @@ pub(crate) struct CompiledCommandProjection { event_set: Vec, capabilities: ProjectionCapabilities, preview: PreviewPlan, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pure_reduces: Vec, fallback: ManifestProjectionFallback, #[serde(skip)] selected_models: BTreeSet, } +/// Client pure-reduce IR (`pureReduces` on the projection artifact). +/// +/// Field names match the rest of the projection preview wire (`occurrence_ordinal`, +/// `projection_refs` snake_case). `client_module` / `client_export` are gen-time +/// only (drive `pures.ts`); inventory is taken from the manifest, not this body. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CompiledPureReduce { + /// Stable pure id used as pureFunctions key. + #[serde(rename = "fn")] + pure_fn: String, + /// App `$lib`-relative module without extension (for gen-client pures.ts). + #[serde(skip)] + client_module: String, + /// Named export in that module. + #[serde(skip)] + client_export: String, + scope: PreviewScope, + args: Vec, + assign: Vec, + occurrence_ordinal: u32, + projection_refs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CompiledPureArg { + name: String, + value: PreviewExpression, +} + impl CompiledCommandProjection { pub(crate) fn affected_models(&self) -> BTreeSet { let mut models = BTreeSet::new(); for operation in &self.preview.operations { operation.mutation.collect_models(&mut models); } + for reduce in &self.pure_reduces { + models.insert(reduce.scope.model.clone()); + } for recovery in &self.preview.recoveries { recovery.target.collect_models(&mut models); } @@ -625,6 +659,7 @@ pub(crate) fn compile_command_preview( validate_preview_inventory(&operations, &recoveries)?; let operations = canonicalize_operations(operations)?; let recoveries = canonicalize_recoveries(recoveries, &operations)?; + let pure_reduces = compile_pure_reduces(extension, &projection_refs, &occurrences)?; let compiled = CompiledCommandProjection { version: extension.version, delta_wire_version: PROJECTION_DELTA_WIRE_VERSION, @@ -642,6 +677,7 @@ pub(crate) fn compile_command_preview( operations, recoveries, }, + pure_reduces, fallback: extension.fallback, selected_models, }; @@ -1456,6 +1492,116 @@ fn source_knowledge(source: &ManifestProjectionPreviewSource) -> Knowledge { } } +fn compile_pure_reduces( + extension: &super::super::manifest::ManifestCommandProjection, + projection_refs: &BTreeMap<&str, u32>, + occurrences: &[PreviewOccurrence], +) -> Result, ClientCompileError> { + if extension.pure_reduces.is_empty() { + return Ok(Vec::new()); + } + // Pure reduce is bound to the first preview occurrence when present so the + // JS validator's occurrence_ordinal < occurrenceCount check passes. Pure + // still needs at least one selected program arm (projection_refs). + if projection_refs.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + "pure reduce requires at least one selected projection program", + )); + } + if occurrences.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + "pure reduce requires at least one preview occurrence so auto-optimism can order the overlay", + )); + } + let occurrence_ordinal = 0u32; + let mut refs: Vec = projection_refs.values().copied().collect(); + refs.sort_unstable(); + refs.dedup(); + let mut compiled = Vec::with_capacity(extension.pure_reduces.len()); + for reduce in &extension.pure_reduces { + if reduce.key.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` requires at least one key field", + reduce.fn_name + ), + )); + } + if reduce.assign.is_empty() { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` requires at least one assign field", + reduce.fn_name + ), + )); + } + compiled.push(compile_one_pure_reduce( + reduce, + occurrence_ordinal, + refs.clone(), + )?); + } + Ok(compiled) +} + +fn compile_one_pure_reduce( + reduce: &super::super::manifest::ManifestCommandPureReduce, + occurrence_ordinal: u32, + projection_refs: Vec, +) -> Result { + let mut key = Vec::with_capacity(reduce.key.len()); + for (ordinal, field) in reduce.key.iter().enumerate() { + let Knowledge::Known(value) = source_knowledge(&field.source) else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` key `{}` must resolve from input, default, or trusted preset", + reduce.fn_name, field.name + ), + )); + }; + key.push(PreviewKeyField { + ordinal: ordinal as u32, + field: field.name.clone(), + value, + }); + } + let mut args = Vec::with_capacity(reduce.args.len()); + for arg in &reduce.args { + let Knowledge::Known(value) = source_knowledge(&arg.source) else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure reduce `{}` arg `{}` must resolve from input, default, or trusted preset", + reduce.fn_name, arg.name + ), + )); + }; + args.push(CompiledPureArg { + name: arg.name.clone(), + value, + }); + } + Ok(CompiledPureReduce { + pure_fn: reduce.fn_name.clone(), + client_module: reduce.client_module.clone(), + client_export: reduce.client_export.clone(), + scope: PreviewScope { + partition: PreviewPartition::Unit, + model: reduce.model.clone(), + key, + }, + args, + assign: reduce.assign.clone(), + occurrence_ordinal, + projection_refs, + }) +} + fn evaluate_expression( expression: &ManifestProjectionExpression, slots: &BTreeMap<&str, Knowledge>, diff --git a/distributed_cli/src/client_compiler/projection_delta/wire.rs b/distributed_cli/src/client_compiler/projection_delta/wire.rs index dc7f04a9..63830091 100644 --- a/distributed_cli/src/client_compiler/projection_delta/wire.rs +++ b/distributed_cli/src/client_compiler/projection_delta/wire.rs @@ -211,21 +211,39 @@ impl ProjectionDeltaIdentity { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum ProjectionSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ProjectionSurfaceIdentity { fn validate(&self) -> Result<(), ClientCompileError> { match self { Self::Role { name } => nonempty(name, "role surface"), - Self::Application { name, roles } => { + Self::Application { + name, + eligible_roles, + schema_roles, + } => { nonempty(name, "application surface")?; - if roles.is_empty() { + if eligible_roles.is_empty() { + return Err(invalid( + "ProjectionDelta application eligible roles must be sorted, unique, and non-empty", + )); + } + validate_names(eligible_roles, "application eligible roles")?; + if schema_roles.is_empty() + || !schema_roles + .iter() + .all(|role| eligible_roles.iter().any(|eligible| eligible == role)) + { return Err(invalid( - "ProjectionDelta application roles must be sorted, unique, and non-empty", + "ProjectionDelta application schema roles must be a sorted, unique, non-empty subset of eligible roles", )); } - validate_names(roles, "application roles") + validate_names(schema_roles, "application schema roles") } } } @@ -864,7 +882,8 @@ mod tests { let mut delta = vector(); delta.identity.surface = ProjectionSurfaceIdentity::Application { name: "web".into(), - roles, + eligible_roles: roles, + schema_roles: Vec::new(), }; assert!( delta.canonical_bytes().is_err(), @@ -875,7 +894,8 @@ mod tests { let mut delta = vector(); delta.identity.surface = ProjectionSurfaceIdentity::Application { name: "web".into(), - roles: vec!["admin".into(), "user".into()], + eligible_roles: vec!["admin".into(), "user".into()], + schema_roles: vec!["admin".into(), "user".into()], }; assert!(delta.canonical_bytes().is_ok()); } diff --git a/distributed_cli/src/client_compiler/render/commands.rs b/distributed_cli/src/client_compiler/render/commands.rs index f2e8c204..e0af0beb 100644 --- a/distributed_cli/src/client_compiler/render/commands.rs +++ b/distributed_cli/src/client_compiler/render/commands.rs @@ -21,7 +21,8 @@ pub(super) fn render_commands(manifest: &ClientManifest) -> Result Result Result Result Vec<(String, String, String)> { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for command in &manifest.commands { + let Some(projection) = &command.extensions.projection else { + continue; + }; + for reduce in &projection.pure_reduces { + if seen.insert(reduce.fn_name.clone()) { + out.push(( + reduce.fn_name.clone(), + reduce.client_module.clone(), + reduce.client_export.clone(), + )); + } + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +/// Generate `pures.ts` mapping pure fn ids to client module exports. +pub(super) fn render_pures(manifest: &ClientManifest) -> Result, ClientCompileError> { + let inventory = pure_function_inventory(manifest); + if inventory.is_empty() { + return Ok(None); + } + let mut imports = Vec::new(); + let mut entries = Vec::new(); + for (index, (fn_name, module, export)) in inventory.iter().enumerate() { + let alias = format!("pure_{index}"); + // From generated// to $lib/ + let rel = format!("../../{module}.js"); + imports.push(format!( + "import {{ {export} as {alias} }} from '{rel}';" + )); + entries.push(format!(" {}: {alias}", quoted_property(fn_name))); + } + Ok(Some(format!( + "/** GENERATED by distributed client. Pure functions for projection.pureReduces. */\n\n{}\n\nexport const PURE_FUNCTIONS = {{\n{}\n}} as const;\n", + imports.join("\n"), + entries.join(",\n") + ))) +} + fn validate_command_namespaces(commands: &[ManifestCommand]) -> Result<(), ClientCompileError> { const RESERVED_SEGMENTS: [&str; 3] = ["__proto__", "constructor", "prototype"]; diff --git a/distributed_cli/src/client_compiler/render/operation.rs b/distributed_cli/src/client_compiler/render/operation.rs index 94facd90..8b4858a7 100644 --- a/distributed_cli/src/client_compiler/render/operation.rs +++ b/distributed_cli/src/client_compiler/render/operation.rs @@ -20,7 +20,7 @@ pub(super) fn render_operation_module( let replica_value_import = variable_codec_uses_replica_value(&operation.variable_codec).then_some(", ReplicaValue"); Ok(format!( - "/** GENERATED by dctl client. Do not edit. */\n\ + "/** GENERATED by distributed client. Do not edit. */\n\ import type {{ ReplicaOperationArtifact{} }} from '@hops-ops/distributed/replica';\n\ \n\ {variables}\n\ diff --git a/distributed_cli/src/client_compiler/render/project.rs b/distributed_cli/src/client_compiler/render/project.rs index fdda77d3..81f4b1c9 100644 --- a/distributed_cli/src/client_compiler/render/project.rs +++ b/distributed_cli/src/client_compiler/render/project.rs @@ -46,6 +46,12 @@ pub(crate) fn render_project( path: "commands.ts".into(), contents: render_commands(manifest)?, }); + if let Some(pures) = super::commands::render_pures(manifest)? { + files.push(GeneratedClientFile { + path: "pures.ts".into(), + contents: pures, + }); + } files.push(GeneratedClientFile { path: "protocol.ts".into(), contents: render_protocol(manifest)?, @@ -92,7 +98,7 @@ fn render_protocol(manifest: &ClientManifest) -> Result &'static str { + match self { + Self::MigrationInventory => "migration_inventory", + Self::SurfaceClientManifest => "surface_client_manifest", + Self::GeneratedClientTree => "generated_client_tree", + Self::ApplicationManifest => "application_manifest", + Self::DeploymentPlan => "deployment_plan", + Self::ClientProgramDescriptor => "client_program_descriptor", + Self::ResolvedDeployment => "resolved_deployment", + } + } + + /// Parse a stable wire spelling without accepting arbitrary enum values. + pub fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.as_str() == value) + } +} + +impl fmt::Display for ContractArtifactKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A portable content or owner-defined identity for one artifact. +/// +/// `value` is intentionally opaque to this foundation. Producers may use a +/// content digest or another stable identity, but paths, timestamps, machine +/// locations, environment values, and secrets are not valid identity material. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactIdentity { + /// The semantic kind whose identity is being represented. + pub kind: ContractArtifactKind, + /// Stable identity value, commonly `sha256:`. + pub value: String, +} + +impl ArtifactIdentity { + /// Construct an identity from an already validated stable value. + pub fn new(kind: ContractArtifactKind, value: impl Into) -> Self { + Self { + kind, + value: value.into(), + } + } + + /// Create a SHA-256 identity from canonical bytes. + pub fn from_canonical_bytes(kind: ContractArtifactKind, bytes: &[u8]) -> Self { + Self::new(kind, canonical_digest(bytes)) + } +} + +/// Source references and generator identity for an artifact. +/// +/// `generator` is descriptive metadata only. The catalog never executes it +/// and never interprets it as a shell command. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactProvenance { + /// Catalog-relative authoritative source files or bounded glob patterns. + pub sources: std::collections::BTreeSet, + /// Stable generator identifier, not an executable command. + pub generator: String, + /// Optional source revision recorded by the producer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, + /// Maximum number of files a source glob may resolve to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glob_limit: Option, +} + +/// The immediate predecessor link in the lifecycle chain. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactPredecessor { + /// Catalog entry ID of the predecessor. + #[serde(alias = "entry")] + pub entry_id: String, + /// Identity observed when this artifact was produced. + pub identity: ArtifactIdentity, +} + +/// An environment-owned policy reference without policy values or secrets. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentPolicyReference { + /// Immutable environment policy identity. + pub identity: String, + /// Human-stable policy name. + pub name: String, + /// Portable owner/reference identifier. + pub reference: String, +} + +fn hex_digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(crate) fn canonical_digest(bytes: &[u8]) -> String { + format!("sha256:{}", hex_digest(bytes)) +} diff --git a/distributed_cli/src/contracts/catalog.rs b/distributed_cli/src/contracts/catalog.rs new file mode 100644 index 00000000..68f0a775 --- /dev/null +++ b/distributed_cli/src/contracts/catalog.rs @@ -0,0 +1,1522 @@ +use super::artifact::canonical_digest; +use super::diagnostic::is_secret_like; +use super::{ + ArtifactIdentity, ArtifactPredecessor, ArtifactProvenance, ContractArtifactKind, + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, EnvironmentPolicyReference, +}; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +/// Version of the repository catalog wire format. +pub const CONTRACT_CATALOG_SCHEMA_VERSION: u32 = 1; +/// Version of the declarative client inventory wire format. +pub const CLIENT_DECLARATION_SCHEMA_VERSION: u32 = 1; +/// Maximum accepted catalog or client-inventory source size. +pub const MAX_CATALOG_BYTES: usize = 1024 * 1024; +/// Maximum number of entries in one catalog. +pub const MAX_CATALOG_ENTRIES: usize = 256; +/// Maximum number of physical files walked by one catalog validation. +pub const MAX_CATALOG_FILES: usize = 8_192; +/// Maximum number of unique physical directories walked by one validation. +pub const MAX_CATALOG_DIRECTORIES: usize = 2_048; +/// Maximum number of physical directory entries inspected by one validation. +pub const MAX_CATALOG_DIRECTORY_ENTRIES: usize = 4_096; +/// Maximum physical directory nesting depth accepted during discovery. +pub const MAX_CATALOG_DIRECTORY_DEPTH: usize = 64; +/// Maximum matches permitted for one catalog source glob. +pub const MAX_CATALOG_GLOB_MATCHES: usize = 2_048; + +const MAX_CATALOG_STRING_BYTES: usize = 4 * 1024; +/// Maximum JSON nesting depth accepted before typed catalog deserialization. +pub const MAX_CATALOG_JSON_DEPTH: usize = 24; +const MAX_CLIENT_DECLARATIONS: usize = 64; +const MAX_CLIENT_DOCUMENTS: usize = 64; + +/// A typed, deterministic catalog validation error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractError { + code: ContractDiagnosticCode, + message: String, +} + +impl ContractError { + pub(crate) fn new(code: ContractDiagnosticCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// Stable diagnostic classification for this error. + pub fn code(&self) -> ContractDiagnosticCode { + self.code + } + + /// Safe explanatory message. + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ContractError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for ContractError {} + +/// A stable logical scope in the contract catalog. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractScope { + /// Stable scope ID; it is not a filesystem path. + pub id: String, +} + +/// One reference-only artifact declaration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEntry { + /// Stable catalog entry ID. + #[serde(default)] + pub id: String, + /// Artifact kind owned by another semantic module. + pub kind: ContractArtifactKind, + /// Logical scope containing this artifact. + pub scope: ContractScope, + /// Authoritative semantic owner ID. + pub owner: String, + /// Canonical artifact identity or producer reference. + pub identity: ArtifactIdentity, + /// Source and generator provenance, without semantic payloads. + pub provenance: ArtifactProvenance, + /// Immediate predecessor identity, when this artifact has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub predecessor: Option, + /// Derived output ID to catalog-relative path. + #[serde(default)] + pub outputs: BTreeMap, + /// Lifecycle phases that may consume this reference. + #[serde(default)] + pub lifecycle: BTreeSet, + /// Environment policy identity/name/reference for deployment artifacts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_policy: Option, +} + +/// The repository-level catalog. Its map representation is canonical JSON. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContractCatalog { + /// Catalog schema version. + pub schema_version: u32, + /// Entries keyed by stable entry ID. + #[serde(deserialize_with = "deserialize_entries")] + pub entries: BTreeMap, +} + +impl ContractCatalog { + /// Parse and structurally validate catalog JSON without touching the filesystem. + pub fn from_json_str(input: &str) -> Result { + let value = parse_json_document(input, "catalog")?; + reject_unknown_artifact_kinds(&value)?; + let catalog: Self = serde_json::from_value(value).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse catalog JSON: {error}"), + ) + })?; + catalog.validate_structure()?; + Ok(catalog) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Read, parse, and physically validate a catalog file. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let root = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_root = fs::canonicalize(root).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve catalog repository root: {error}"), + ) + })?; + let canonical_catalog = fs::canonicalize(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("resolve catalog path: {error}"), + ) + })?; + if !canonical_catalog.starts_with(&canonical_root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "catalog path resolves outside the repository root", + )); + } + let metadata = fs::metadata(&canonical_catalog).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog path: {error}"), + ) + })?; + if !metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + "catalog path is not a regular file", + )); + } + let bytes = read_bounded_file(&canonical_catalog, MAX_CATALOG_BYTES, "catalog")?; + let input = std::str::from_utf8(&bytes).map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "catalog is not UTF-8", + ) + })?; + let catalog = Self::from_json_str(input)?; + catalog.validate_paths(canonical_root)?; + Ok(catalog) + } + + /// Alias for [`Self::from_path`]. + pub fn load(path: impl AsRef) -> Result { + Self::from_path(path) + } + + /// Serialize sorted catalog maps/sets as canonical bytes. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate_structure()?; + serde_json::to_vec(self).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("serialize canonical catalog: {error}"), + ) + }) + } + + /// Validate all declared paths against a repository root without writing. + pub fn validate_paths(&self, root: impl AsRef) -> Result<(), ContractError> { + self.validate_structure()?; + let root = fs::canonicalize(root.as_ref()).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve repository root: {error}"), + ) + })?; + let metadata = fs::metadata(&root).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect repository root: {error}"), + ) + })?; + if !metadata.is_dir() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + "repository root is not a directory", + )); + } + + let mut walker = PhysicalPathWalker::new(root); + for entry in self.entries.values() { + for source in &entry.provenance.sources { + walker.resolve_declared_path( + source, + entry.provenance.glob_limit, + "catalog source", + )?; + } + for output in entry.outputs.values() { + if contains_glob(output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("output path `{output}` may not contain a glob"), + )); + } + walker.resolve_declared_path(output, None, "catalog output")?; + } + } + Ok(()) + } + + /// Validate and return a pure aggregate result for a read-only check. + pub fn check(&self, root: impl AsRef) -> ContractCheckResult { + let mut result = ContractCheckResult::default(); + if let Err(error) = self.validate_structure() { + result.push(diagnostic_for_error(&error)); + return result; + } + let canonical_catalog = match self.canonical_bytes() { + Ok(bytes) => bytes, + Err(error) => { + result.push(diagnostic_for_error(&error)); + return result; + } + }; + result.catalog_identity = Some(canonical_digest(&canonical_catalog)); + result.artifacts = self + .entries + .iter() + .map(|(id, entry)| (id.clone(), entry.identity.clone())) + .collect(); + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + result + } + + /// Alias emphasizing that collection is pure and read-only. + pub fn collect(&self, root: impl AsRef) -> ContractCheckResult { + self.check(root) + } + + fn validate_structure(&self) -> Result<(), ContractError> { + if self.schema_version != CONTRACT_CATALOG_SCHEMA_VERSION { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "unsupported catalog schema version {}; expected {}", + self.schema_version, CONTRACT_CATALOG_SCHEMA_VERSION + ), + )); + } + if self.entries.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "catalog must declare at least one entry", + )); + } + if self.entries.len() > MAX_CATALOG_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog declares {} entries; maximum is {MAX_CATALOG_ENTRIES}", + self.entries.len() + ), + )); + } + + let mut scopes = BTreeMap::<&str, &str>::new(); + let mut owners = BTreeMap::<&str, &str>::new(); + let mut outputs = BTreeMap::<&str, (&str, &str)>::new(); + let mut output_paths = BTreeMap::<&str, (&str, &str)>::new(); + + for (entry_id, entry) in &self.entries { + validate_identifier(entry_id, "catalog entry ID")?; + if entry.id != *entry_id { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "entry key `{entry_id}` does not match entry ID `{}`", + entry.id + ), + )); + } + validate_identifier(&entry.scope.id, "contract scope ID")?; + validate_identifier(&entry.owner, "contract owner ID")?; + if let Some(previous) = scopes.insert(&entry.scope.id, entry_id) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "scope `{}` is declared by both `{previous}` and `{entry_id}`", + entry.scope.id + ), + )); + } + if let Some(previous) = owners.insert(&entry.owner, entry_id) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOwner, + format!( + "owner `{}` is declared by both `{previous}` and `{entry_id}`", + entry.owner + ), + )); + } + if entry.identity.kind != entry.kind { + return Err(ContractError::new( + ContractDiagnosticCode::ChainKindMismatch, + format!("entry `{entry_id}` identity kind does not match its artifact kind"), + )); + } + validate_stable_value(&entry.identity.value, "artifact identity")?; + if entry.provenance.sources.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("entry `{entry_id}` must declare at least one source"), + )); + } + validate_stable_value(&entry.provenance.generator, "generator identity")?; + if let Some(revision) = &entry.provenance.source_revision { + validate_stable_value(revision, "source revision")?; + } + if let Some(limit) = entry.provenance.glob_limit { + if limit == 0 || limit > MAX_CATALOG_GLOB_MATCHES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "source glob limit {limit} is outside 1..={MAX_CATALOG_GLOB_MATCHES}" + ), + )); + } + } + for source in &entry.provenance.sources { + validate_catalog_path(source, true)?; + if contains_glob(source) { + validate_bounded_glob(source)?; + if entry.provenance.glob_limit.is_none() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("source glob `{source}` has no finite match limit"), + )); + } + } + } + if entry.outputs.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("entry `{entry_id}` must declare at least one output"), + )); + } + if entry.outputs.len() > MAX_CATALOG_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("entry `{entry_id}` declares too many outputs"), + )); + } + for (output_id, output_path) in &entry.outputs { + validate_identifier(output_id, "catalog output ID")?; + validate_catalog_path(output_path, false)?; + if let Some((previous_entry, previous_path)) = + outputs.insert(output_id, (entry_id, output_path)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "output ID `{output_id}` is declared by `{previous_entry}` ({previous_path}) and `{entry_id}` ({output_path})" + ), + )); + } + if let Some((previous_entry, previous_id)) = + output_paths.insert(output_path, (entry_id, output_id)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "output path `{output_path}` is declared by `{previous_entry}` ({previous_id}) and `{entry_id}` ({output_id})" + ), + )); + } + } + for lifecycle in &entry.lifecycle { + validate_identifier(lifecycle, "lifecycle policy")?; + } + if let Some(policy) = &entry.environment_policy { + validate_stable_value(&policy.identity, "environment policy identity")?; + validate_identifier(&policy.name, "environment policy name")?; + validate_stable_value(&policy.reference, "environment policy reference")?; + } + if let Some(predecessor) = &entry.predecessor { + validate_identifier(&predecessor.entry_id, "predecessor entry ID")?; + validate_stable_value(&predecessor.identity.value, "predecessor identity")?; + } + } + + self.validate_predecessors() + } + + fn validate_predecessors(&self) -> Result<(), ContractError> { + for (entry_id, entry) in &self.entries { + let Some(predecessor) = &entry.predecessor else { + continue; + }; + let Some(predecessor_entry) = self.entries.get(&predecessor.entry_id) else { + return Err(ContractError::new( + ContractDiagnosticCode::ChainMissingPredecessor, + format!( + "entry `{entry_id}` references missing predecessor `{}`", + predecessor.entry_id + ), + )); + }; + if predecessor.identity.kind != predecessor_entry.kind { + return Err(ContractError::new( + ContractDiagnosticCode::ChainKindMismatch, + format!( + "entry `{entry_id}` predecessor `{}` declares kind {} but the entry is {}", + predecessor.entry_id, predecessor.identity.kind, predecessor_entry.kind + ), + )); + } + if predecessor.identity.value != predecessor_entry.identity.value { + return Err(ContractError::new( + ContractDiagnosticCode::ChainIdentityMismatch, + format!( + "entry `{entry_id}` predecessor `{}` has a stale identity", + predecessor.entry_id + ), + )); + } + } + + for start in self.entries.keys() { + let mut seen = BTreeSet::new(); + let mut current = start.as_str(); + while let Some(entry) = self.entries.get(current) { + if !seen.insert(current.to_string()) { + return Err(ContractError::new( + ContractDiagnosticCode::ChainCycle, + format!("predecessor chain cycles at `{current}`"), + )); + } + let Some(predecessor) = &entry.predecessor else { + break; + }; + current = &predecessor.entry_id; + } + } + Ok(()) + } +} + +/// One stable client declaration shared by Rust validation and the Vite config. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientDeclaration { + /// Virtual module exposed to the application. + pub module: String, + /// Rust-declared application surface. + pub surface: String, + /// Co-located GraphQL files or bounded globs. + #[serde(deserialize_with = "deserialize_documents")] + pub documents: BTreeSet, + /// Compiler-owned output directory, relative to the UI root. + pub output: String, + /// Optional Rust surface export for non-default clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_entrypoint: Option, +} + +/// Versioned application-owned client inventory. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientInventory { + /// Inventory schema version. + pub schema_version: u32, + /// Stable client declarations. + pub clients: Vec, +} + +impl ClientInventory { + /// Parse and validate the shared client inventory without executing anything. + pub fn from_json_str(input: &str) -> Result { + let value = parse_json_document(input, "client inventory")?; + let inventory: Self = serde_json::from_value(value).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse client inventory JSON: {error}"), + ) + })?; + inventory.validate()?; + Ok(inventory) + } + + /// Read and validate a client inventory file. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = read_bounded_file(path.as_ref(), MAX_CATALOG_BYTES, "client inventory")?; + let input = std::str::from_utf8(&bytes).map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + "client inventory is not UTF-8", + ) + })?; + Self::from_json_str(input) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Serialize a normalized client order and sorted document sets. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate()?; + let mut clients = self.clients.clone(); + clients.sort_by(|left, right| left.module.cmp(&right.module)); + serde_json::to_vec(&Self { + schema_version: self.schema_version, + clients, + }) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("serialize canonical client inventory: {error}"), + ) + }) + } + + /// Validate the declaration schema and uniqueness constraints. + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema_version != CLIENT_DECLARATION_SCHEMA_VERSION { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!( + "unsupported client inventory schema version {}; expected {}", + self.schema_version, CLIENT_DECLARATION_SCHEMA_VERSION + ), + )); + } + if self.clients.is_empty() || self.clients.len() > MAX_CLIENT_DECLARATIONS { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("client inventory must contain 1..={MAX_CLIENT_DECLARATIONS} declarations"), + )); + } + let mut modules = BTreeSet::new(); + let mut surfaces = BTreeSet::new(); + let mut outputs = BTreeSet::new(); + for client in &self.clients { + validate_client_module(&client.module)?; + validate_identifier(&client.surface, "client surface")?; + validate_catalog_path(&client.output, false)?; + if contains_glob(&client.output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("client output `{}` may not contain a glob", client.output), + )); + } + if client.documents.is_empty() || client.documents.len() > MAX_CLIENT_DOCUMENTS { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "client `{}` must contain 1..={MAX_CLIENT_DOCUMENTS} documents", + client.module + ), + )); + } + for document in &client.documents { + validate_client_document(document)?; + } + if let Some(entrypoint) = &client.manifest_entrypoint { + validate_entrypoint(entrypoint)?; + } + if !modules.insert(&client.module) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "client module `{}` is declared more than once", + client.module + ), + )); + } + if !surfaces.insert(&client.surface) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateScope, + format!( + "client surface `{}` is declared more than once", + client.surface + ), + )); + } + if !outputs.insert(&client.output) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogDuplicateOutput, + format!( + "client output `{}` is declared more than once", + client.output + ), + )); + } + } + Ok(()) + } +} + +fn deserialize_entries<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum EntryContainer { + Map(BTreeMap), + List(Vec), + } + + let container = EntryContainer::deserialize(deserializer)?; + let mut entries = BTreeMap::new(); + match container { + EntryContainer::Map(map) => { + for (key, mut entry) in map { + if entry.id.is_empty() { + entry.id = key.clone(); + } + if entry.id != key { + return Err(D::Error::custom(format!( + "entry key `{key}` does not match entry ID `{}`", + entry.id + ))); + } + entries.insert(key, entry); + } + } + EntryContainer::List(list) => { + for entry in list { + if entry.id.is_empty() { + return Err(D::Error::custom("catalog list entries require an id")); + } + let id = entry.id.clone(); + if entries.insert(id.clone(), entry).is_some() { + return Err(D::Error::custom(format!("duplicate catalog entry `{id}`"))); + } + } + } + } + Ok(entries) +} + +fn deserialize_documents<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let documents = Vec::::deserialize(deserializer)?; + let mut unique_documents = BTreeSet::new(); + for document in documents { + if !unique_documents.insert(document.clone()) { + return Err(D::Error::custom(format!( + "duplicate client document `{document}`" + ))); + } + } + Ok(unique_documents) +} + +fn parse_json_document(input: &str, label: &str) -> Result { + if input.len() > MAX_CATALOG_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "{label} is {} bytes; maximum supported size is {MAX_CATALOG_BYTES}", + input.len() + ), + )); + } + let value: Value = serde_json::from_str(input).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("parse {label} JSON: {error}"), + ) + })?; + validate_json_value(&value, 0, "$", label)?; + Ok(value) +} + +fn validate_json_value( + value: &Value, + depth: usize, + path: &str, + label: &str, +) -> Result<(), ContractError> { + if depth > MAX_CATALOG_JSON_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} exceeds maximum JSON nesting depth"), + )); + } + match value { + Value::Object(object) => { + for (key, child) in object { + if is_forbidden_field(key) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} field `{key}` is not permitted in portable metadata"), + )); + } + validate_json_value(child, depth + 1, &format!("{path}.{key}"), label)?; + } + } + Value::Array(array) => { + if array.len() > MAX_CATALOG_ENTRIES * 32 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} array at {path} is too large"), + )); + } + for (index, child) in array.iter().enumerate() { + validate_json_value(child, depth + 1, &format!("{path}[{index}]"), label)?; + } + } + Value::String(string) => { + if string.len() > MAX_CATALOG_STRING_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} string at {path} is too large"), + )); + } + if is_secret_like(string) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains a credential-like value at {path}"), + )); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +fn reject_unknown_artifact_kinds(value: &Value) -> Result<(), ContractError> { + let Some(entries) = value.get("entries") else { + return Ok(()); + }; + match entries { + Value::Object(entries) => { + for (entry_id, entry) in entries { + check_kind(entry, entry_id)?; + } + } + Value::Array(entries) => { + for (index, entry) in entries.iter().enumerate() { + check_kind(entry, &format!("entries[{index}]"))?; + } + } + _ => {} + } + Ok(()) +} + +fn check_kind(value: &Value, entry_id: &str) -> Result<(), ContractError> { + let Some(kind) = value.get("kind") else { + return Ok(()); + }; + let Some(kind) = kind.as_str() else { + return Ok(()); + }; + if ContractArtifactKind::parse(kind).is_none() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnknownKind, + format!("entry `{entry_id}` uses unknown artifact kind `{kind}`"), + )); + } + Ok(()) +} + +fn is_forbidden_field(field: &str) -> bool { + matches!( + field.to_ascii_lowercase().as_str(), + "credential" + | "credentials" + | "connection_string" + | "connectionstring" + | "password" + | "token" + | "secret" + | "secrets" + | "private_key" + | "privatekey" + | "header" + | "headers" + | "environment" + | "environment_value" + | "environment_values" + | "raw_environment" + | "raw_environment_values" + | "env" + | "key" + ) +} + +fn validate_identifier(value: &str, label: &str) -> Result<(), ContractError> { + if value.is_empty() || value.trim() != value { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} must be a non-empty trimmed value"), + )); + } + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains credential-like material"), + )); + } + if value.len() > MAX_CATALOG_STRING_BYTES + || value.contains('\0') + || value.contains('\\') + || value.contains("..") + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} is too long or not portable"), + )); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte)) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} contains unsupported characters"), + )); + } + Ok(()) +} + +fn validate_stable_value(value: &str, label: &str) -> Result<(), ContractError> { + if value.is_empty() || value.trim() != value || value.len() > MAX_CATALOG_STRING_BYTES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("{label} must be a non-empty trimmed value"), + )); + } + if is_secret_like(value) + || value.contains('\0') + || value.contains('\\') + || value.starts_with('/') + || value.starts_with('~') + || value.contains("/Users/") + || value.contains("/home/") + || value.contains("\\Users\\") + || value.contains("\\home\\") + || looks_like_timestamp(value) + { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + format!("{label} contains non-portable or sensitive material"), + )); + } + Ok(()) +} + +fn looks_like_timestamp(value: &str) -> bool { + value.len() >= 20 + && value.as_bytes().get(4) == Some(&b'-') + && value.as_bytes().get(7) == Some(&b'-') + && value.as_bytes().get(10) == Some(&b'T') +} + +fn validate_catalog_path(value: &str, allow_glob: bool) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "catalog metadata contains credential-like path material", + )); + } + if value.is_empty() + || value.trim() != value + || value.len() > MAX_CATALOG_STRING_BYTES + || value.contains('\0') + || value.contains('\\') + || value.starts_with('/') + || value.starts_with('~') + || (value.len() >= 2 && value.as_bytes()[1] == b':') + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("catalog path `{value}` is absolute or not portable"), + )); + } + for component in Path::new(value).components() { + if !matches!(component, Component::Normal(_)) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("catalog path `{value}` contains parent or root traversal"), + )); + } + } + if !allow_glob && contains_glob(value) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("catalog path `{value}` contains an unbounded glob"), + )); + } + Ok(()) +} + +fn validate_client_module(value: &str) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "client module contains credential-like material", + )); + } + if value.len() > MAX_CATALOG_STRING_BYTES + || value.trim() != value + || value.contains('\0') + || value.contains('\\') + || value.contains("..") + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` is not portable"), + )); + } + let mut segments = value.split('/'); + if segments.next() != Some("$distributed") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` must start with $distributed"), + )); + } + if segments.any(|segment| { + segment.is_empty() + || !segment + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client module `{value}` contains an unsupported segment"), + )); + } + Ok(()) +} + +fn validate_client_document(value: &str) -> Result<(), ContractError> { + validate_catalog_path(value, true)?; + if !value.ends_with(".graphql") && !value.ends_with(".gql") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client document `{value}` must end in .graphql or .gql"), + )); + } + if value.contains("**") + || value.starts_with('*') + || value.starts_with('?') + || value.starts_with('[') + || value.starts_with(']') + || value.starts_with('{') + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("client document glob `{value}` is unbounded"), + )); + } + Ok(()) +} + +fn validate_entrypoint(value: &str) -> Result<(), ContractError> { + if is_secret_like(value) { + return Err(ContractError::new( + ContractDiagnosticCode::EnvironmentValue, + "client manifest entrypoint contains credential-like material", + )); + } + if value.is_empty() + || value.len() > MAX_CATALOG_STRING_BYTES + || value.split("::").any(|segment| { + segment.is_empty() + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInvalid, + format!("client manifest entrypoint `{value}` is not a Rust path"), + )); + } + Ok(()) +} + +fn contains_glob(value: &str) -> bool { + value + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{')) +} + +fn validate_bounded_glob(value: &str) -> Result<(), ContractError> { + if value.contains("**") { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("recursive catalog glob `{value}` is not bounded by a match limit"), + )); + } + let components = value.split('/').collect::>(); + let glob_components = components + .iter() + .enumerate() + .filter(|(_, component)| contains_glob(component)) + .map(|(index, _)| index) + .collect::>(); + if glob_components.len() != 1 || glob_components[0] != components.len() - 1 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("catalog glob `{value}` must match entries in one bounded directory"), + )); + } + Ok(()) +} + +fn glob_parent(value: &str) -> PathBuf { + value + .rsplit_once('/') + .map_or_else(PathBuf::new, |(parent, _)| PathBuf::from(parent)) +} + +fn read_bounded_file(path: &Path, limit: usize, label: &str) -> Result, ContractError> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} must not be a symlink"), + )); + } + if !metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("{label} must be a regular file"), + )); + } + let file_size = metadata.len(); + if file_size > limit as u64 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} is {file_size} bytes; maximum supported size is {limit}"), + )); + } + let read_limit = limit.saturating_add(1); + let file = fs::File::open(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + let opened_metadata = file.metadata().map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect opened {label}: {error}"), + ) + })?; + if !opened_metadata.is_file() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("opened {label} is not a regular file"), + )); + } + let opened_size = opened_metadata.len(); + if opened_size > limit as u64 { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("opened {label} is {opened_size} bytes; maximum supported size is {limit}"), + )); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(read_limit as u64) + .read_to_end(&mut bytes) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label}: {error}"), + ) + })?; + if bytes.len() > limit { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "{label} is {} bytes; maximum supported size is {limit}", + bytes.len() + ), + )); + } + Ok(bytes) +} + +struct PhysicalPathWalker { + root: PathBuf, + files: BTreeSet, + directories: BTreeSet, + directory_entries: usize, +} + +impl PhysicalPathWalker { + fn new(root: PathBuf) -> Self { + Self { + root, + files: BTreeSet::new(), + directories: BTreeSet::new(), + directory_entries: 0, + } + } + + fn resolve_declared_path( + &mut self, + declared: &str, + glob_limit: Option, + label: &str, + ) -> Result<(), ContractError> { + if contains_glob(declared) { + let limit = glob_limit.ok_or_else(|| { + ContractError::new( + ContractDiagnosticCode::CatalogUnboundedGlob, + format!("{label} glob `{declared}` has no finite match limit"), + ) + })?; + validate_bounded_glob(declared)?; + let final_component = declared.rsplit('/').next().ok_or_else(|| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob `{declared}` has no final component"), + ) + })?; + let pattern = glob::Pattern::new(final_component).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("parse {label} glob `{declared}`: {error}"), + ) + })?; + let matches = self.guard_glob_candidates( + &glob_parent(declared), + declared, + label, + &pattern, + limit, + )?; + for matched in &matches { + self.walk_canonical_target(matched, declared, label)?; + } + if matches.is_empty() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob `{declared}` matched no entries"), + )); + } + return Ok(()); + } + self.walk_target(&self.root.join(declared), declared, label) + } + + fn guard_glob_candidates( + &mut self, + parent: &Path, + declared: &str, + label: &str, + pattern: &glob::Pattern, + match_limit: usize, + ) -> Result, ContractError> { + let candidate_directory = fs::canonicalize(self.root.join(parent)).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve {label} glob directory `{declared}`: {error}"), + ) + })?; + if !candidate_directory.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob directory `{declared}` escapes the repository root"), + )); + } + let metadata = fs::metadata(&candidate_directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} glob directory `{declared}`: {error}"), + ) + })?; + if !metadata.is_dir() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("{label} glob directory `{declared}` is not a directory"), + )); + } + let depth = candidate_directory + .strip_prefix(&self.root) + .map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob directory `{declared}` escapes the repository root"), + ) + })? + .components() + .count(); + if depth > MAX_CATALOG_DIRECTORY_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory depth exceeds {MAX_CATALOG_DIRECTORY_DEPTH} at `{declared}`" + ), + )); + } + if self.directories.insert(candidate_directory.clone()) + && self.directories.len() > MAX_CATALOG_DIRECTORIES + { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog directories exceed {MAX_CATALOG_DIRECTORIES} at `{declared}`"), + )); + } + let mut entries = Vec::new(); + for entry in fs::read_dir(&candidate_directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label} glob directory `{declared}`: {error}"), + ) + })? { + let entry = entry.map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read {label} glob directory `{declared}`: {error}"), + ) + })?; + self.record_directory_entry(declared)?; + entries.push(entry); + } + entries.sort_by_key(|entry| entry.file_name()); + + let mut matches = Vec::new(); + for entry in entries { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if !pattern.matches(file_name) { + continue; + } + let path = entry.path(); + let symlink = fs::symlink_metadata(&path) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} glob candidate `{declared}`: {error}"), + ) + })? + .file_type() + .is_symlink(); + let canonical = fs::canonicalize(&path).map_err(|error| { + ContractError::new( + if symlink { + ContractDiagnosticCode::CatalogSymlinkEscape + } else { + ContractDiagnosticCode::CatalogPath + }, + format!("resolve {label} glob candidate `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} glob `{declared}` resolves outside the repository root"), + )); + } + matches.push(canonical); + if matches.len() > match_limit { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("{label} glob `{declared}` exceeds limit {match_limit}"), + )); + } + } + Ok(matches) + } + + fn walk_target( + &mut self, + path: &Path, + declared: &str, + label: &str, + ) -> Result<(), ContractError> { + let canonical = fs::canonicalize(path).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("resolve {label} `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} `{declared}` resolves outside the repository root"), + )); + } + self.walk_canonical_target(&canonical, declared, label) + } + + fn walk_canonical_target( + &mut self, + canonical: &Path, + declared: &str, + label: &str, + ) -> Result<(), ContractError> { + let metadata = fs::metadata(canonical).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect {label} `{declared}`: {error}"), + ) + })?; + if metadata.is_file() { + self.record_file(canonical.to_path_buf(), declared) + } else if metadata.is_dir() { + let depth = self.relative_depth(canonical, declared)?; + self.walk_directory(canonical, declared, depth) + } else { + Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("{label} `{declared}` is not a regular file or directory"), + )) + } + } + + fn relative_depth(&self, path: &Path, declared: &str) -> Result { + path.strip_prefix(&self.root) + .map(|relative| relative.components().count()) + .map_err(|_| { + ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("catalog path `{declared}` escapes the repository root"), + ) + }) + } + + fn walk_directory( + &mut self, + directory: &Path, + declared: &str, + initial_depth: usize, + ) -> Result<(), ContractError> { + let mut pending = vec![(directory.to_path_buf(), initial_depth)]; + while let Some((directory, depth)) = pending.pop() { + if depth > MAX_CATALOG_DIRECTORY_DEPTH { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory depth exceeds {MAX_CATALOG_DIRECTORY_DEPTH} at `{declared}`" + ), + )); + } + if !self.directories.insert(directory.clone()) { + continue; + } + if self.directories.len() > MAX_CATALOG_DIRECTORIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog directories exceed {MAX_CATALOG_DIRECTORIES} at `{declared}`"), + )); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&directory).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read catalog directory `{declared}`: {error}"), + ) + })? { + let entry = entry.map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("read catalog directory `{declared}`: {error}"), + ) + })?; + self.record_directory_entry(declared)?; + entries.push(entry); + } + entries.sort_by_key(|entry| entry.file_name()); + + let mut child_directories = Vec::new(); + for entry in entries { + let path = entry.path(); + let symlink = fs::symlink_metadata(&path) + .map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog entry `{declared}`: {error}"), + ) + })? + .file_type() + .is_symlink(); + let canonical = fs::canonicalize(&path).map_err(|error| { + ContractError::new( + if symlink { + ContractDiagnosticCode::CatalogSymlinkEscape + } else { + ContractDiagnosticCode::CatalogPath + }, + format!("resolve catalog entry `{declared}`: {error}"), + ) + })?; + if !canonical.starts_with(&self.root) { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("catalog entry under `{declared}` escapes the repository root"), + )); + } + let metadata = fs::metadata(&canonical).map_err(|error| { + ContractError::new( + ContractDiagnosticCode::CatalogPath, + format!("inspect catalog entry `{declared}`: {error}"), + ) + })?; + if metadata.is_dir() { + child_directories.push(canonical); + } else if metadata.is_file() { + self.record_file(canonical, declared)?; + } else { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSpecialFile, + format!("catalog entry under `{declared}` is special or unsupported"), + )); + } + } + for child in child_directories.into_iter().rev() { + pending.push((child, depth + 1)); + } + } + Ok(()) + } + + fn record_directory_entry(&mut self, declared: &str) -> Result<(), ContractError> { + self.directory_entries += 1; + if self.directory_entries > MAX_CATALOG_DIRECTORY_ENTRIES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!( + "catalog directory entries exceed {MAX_CATALOG_DIRECTORY_ENTRIES} at `{declared}`" + ), + )); + } + Ok(()) + } + + fn record_file(&mut self, path: PathBuf, declared: &str) -> Result<(), ContractError> { + if self.files.insert(path) && self.files.len() > MAX_CATALOG_FILES { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogInputLimit, + format!("catalog paths exceed {MAX_CATALOG_FILES} physical files at `{declared}`"), + )); + } + Ok(()) + } +} + +fn diagnostic_for_error(error: &ContractError) -> ContractDiagnostic { + ContractDiagnostic::new( + error.code(), + None, + None::<&str>, + "contract-catalog", + std::iter::empty::<&str>(), + std::iter::empty::<&str>(), + None::<&str>, + None, + None, + None::<&str>, + None, + "inspect distributed.contracts.json", + ) + .with_detail(error.message()) +} diff --git a/distributed_cli/src/contracts/chain.rs b/distributed_cli/src/contracts/chain.rs new file mode 100644 index 00000000..642cb0cd --- /dev/null +++ b/distributed_cli/src/contracts/chain.rs @@ -0,0 +1,174 @@ +//! Generic predecessor-staleness diagnostics for the approved artifact chain. +//! +//! This module does not reinterpret CMP/DPL payload semantics. It only compares +//! expected versus observed predecessor identities and reports owner/path. + +use super::artifact::{ArtifactIdentity, ArtifactPredecessor, ContractArtifactKind}; +use super::diagnostic::{ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// One observed predecessor link to validate. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ObservedPredecessor { + /// Catalog entry or source path that owns this check. + pub owner: String, + /// Spec/source path for diagnostics. + pub source_path: String, + /// The predecessor the artifact claims. + pub claimed: ArtifactPredecessor, + /// The currently recorded identity for that predecessor entry, if any. + pub observed: Option, +} + +/// Validate claimed predecessors against observed catalog identities (no writes). +pub fn check_predecessor_chain( + observations: impl IntoIterator, +) -> ContractCheckResult { + let mut diagnostics = BTreeSet::new(); + for observation in observations { + match &observation.observed { + None => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainMissingPredecessor, + Some(observation.claimed.identity.kind), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.entry_id"), + Some(observation.claimed.entry_id.as_str()), + None, + Some("repair_catalog_predecessor"), + None, + "distributed contracts check --scope catalog", + ) + .with_detail(format!( + "owner `{}` at `{}` references missing predecessor `{}`", + observation.owner, observation.source_path, observation.claimed.entry_id + )), + ); + } + Some(observed) if observed.kind != observation.claimed.identity.kind => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainKindMismatch, + Some(observation.claimed.identity.kind), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.identity.kind"), + Some(observation.claimed.identity.kind.as_str()), + Some(observed.kind.as_str()), + Some("repair_predecessor_kind"), + None, + "distributed contracts check --scope catalog", + ) + .with_detail(format!( + "owner `{}` at `{}` expected predecessor kind {} but observed {}", + observation.owner, + observation.source_path, + observation.claimed.identity.kind, + observed.kind + )), + ); + } + Some(observed) if observed.value != observation.claimed.identity.value => { + diagnostics.insert( + ContractDiagnostic::new( + ContractDiagnosticCode::ChainIdentityMismatch, + Some(ContractArtifactKind::ApplicationManifest), + None::<&str>, + &observation.owner, + [observation.source_path.as_str()], + std::iter::empty::<&str>(), + Some("predecessor.identity.value"), + Some(observation.claimed.identity.value.as_str()), + Some(observed.value.as_str()), + Some("accept_application_manifest"), + None, + "distributed contracts accept --scope application_manifest", + ) + .with_detail(format!( + "owner `{}` at `{}` has stale predecessor `{}`", + observation.owner, observation.source_path, observation.claimed.entry_id + )), + ); + } + Some(_) => {} + } + } + ContractCheckResult { + catalog_identity: None, + artifacts: Default::default(), + diagnostics, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::artifact::ArtifactPredecessor; + + #[test] + fn stale_application_predecessor_reports_owner_path_and_identities() { + let result = check_predecessor_chain([ObservedPredecessor { + owner: "deployment-plan/todo".into(), + source_path: "specs/application-composition/index#DeploymentPlan".into(), + claimed: ArtifactPredecessor { + entry_id: "app-manifest".into(), + identity: ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:expected", + ), + }, + observed: Some(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:observed-stale", + )), + }]); + assert_eq!(result.diagnostics.len(), 1); + let diagnostic = result.diagnostics.iter().next().unwrap(); + assert_eq!( + diagnostic.code, + ContractDiagnosticCode::ChainIdentityMismatch + ); + assert_eq!(diagnostic.owner, "deployment-plan/todo"); + assert!(diagnostic + .source_paths + .iter() + .any(|path| path.contains("application-composition"))); + assert_eq!( + diagnostic.expected.as_ref().map(|v| v.as_str()), + Some("sha256:expected") + ); + assert_eq!( + diagnostic.observed.as_ref().map(|v| v.as_str()), + Some("sha256:observed-stale") + ); + } + + #[test] + fn missing_predecessor_is_deterministic_and_no_write() { + let result = check_predecessor_chain([ObservedPredecessor { + owner: "plan".into(), + source_path: "plans/todo.json".into(), + claimed: ArtifactPredecessor { + entry_id: "missing".into(), + identity: ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:x", + ), + }, + observed: None, + }]); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!( + result.diagnostics.iter().next().unwrap().code, + ContractDiagnosticCode::ChainMissingPredecessor + ); + } +} diff --git a/distributed_cli/src/contracts/classification.rs b/distributed_cli/src/contracts/classification.rs new file mode 100644 index 00000000..a0766138 --- /dev/null +++ b/distributed_cli/src/contracts/classification.rs @@ -0,0 +1,160 @@ +//! Lifecycle decision classification for semantic and wire identity changes. +//! +//! Classification never guesses that every schema diff requires a protocol +//! bump. Each identity owner maps to a distinct required decision. + +use super::diagnostic::ContractDiagnosticCode; +use super::snapshots::{SnapshotChange, SnapshotDiff}; +use serde::{Deserialize, Serialize}; + +/// The decision an operator/tool must make for one detected change. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleDecision { + /// Accept surface/client manifest wire change. + AcceptManifestWire, + /// Accept protocol/semantic fingerprint change. + AcceptProtocolSemantic, + /// Accept application-manifest logical identity change. + AcceptApplicationManifest, + /// Accept deployment-plan identity change. + AcceptDeploymentPlan, + /// No acceptance required (informational). + None, +} + +impl LifecycleDecision { + pub const fn as_str(self) -> &'static str { + match self { + Self::AcceptManifestWire => "accept_manifest_wire", + Self::AcceptProtocolSemantic => "accept_protocol_semantic", + Self::AcceptApplicationManifest => "accept_application_manifest", + Self::AcceptDeploymentPlan => "accept_deployment_plan", + Self::None => "none", + } + } +} + +/// One classified change with a stable diagnostic code. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClassifiedChange { + pub path: String, + pub decision: LifecycleDecision, + pub code: ContractDiagnosticCode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// Classify a snapshot diff into explicit lifecycle decisions. +pub fn classify_snapshot_diff(diff: &SnapshotDiff) -> Vec { + diff.changes + .iter() + .map(|change| classify_change(&diff.kind, change)) + .collect() +} + +fn classify_change(kind: &str, change: &SnapshotChange) -> ClassifiedChange { + let (decision, code) = match kind { + "surface_client_manifest" | "generated_client_tree" => { + if change.path.contains("protocol") { + ( + LifecycleDecision::AcceptProtocolSemantic, + ContractDiagnosticCode::ProtocolDrift, + ) + } else { + ( + LifecycleDecision::AcceptManifestWire, + ContractDiagnosticCode::ManifestVersion, + ) + } + } + "application_manifest" => ( + LifecycleDecision::AcceptApplicationManifest, + ContractDiagnosticCode::SchemaDrift, + ), + "deployment_plan" => ( + LifecycleDecision::AcceptDeploymentPlan, + ContractDiagnosticCode::ChainStale, + ), + _ => { + if change.path.contains("protocol") { + ( + LifecycleDecision::AcceptProtocolSemantic, + ContractDiagnosticCode::ProtocolDrift, + ) + } else { + (LifecycleDecision::None, ContractDiagnosticCode::SchemaDrift) + } + } + }; + ClassifiedChange { + path: change.path.clone(), + decision, + code, + before: change.before.clone(), + after: change.after.clone(), + } +} + +/// Prove that manifest-wire and protocol-semantic changes require distinct decisions. +pub fn decisions_are_distinct(changes: &[ClassifiedChange]) -> bool { + let mut seen = std::collections::BTreeSet::new(); + for change in changes { + if change.decision == LifecycleDecision::None { + continue; + } + seen.insert(change.decision); + } + // Distinctness is only meaningful when both families appear. + let has_wire = seen.contains(&LifecycleDecision::AcceptManifestWire); + let has_protocol = seen.contains(&LifecycleDecision::AcceptProtocolSemantic); + !(has_wire && has_protocol && changes.iter().any(|c| { + c.decision == LifecycleDecision::AcceptManifestWire + && c.path.contains("protocol") + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::snapshots::{diff_snapshots, snapshot_from_json}; + use serde_json::json; + + #[test] + fn manifest_and_protocol_changes_require_distinct_decisions() { + let before = snapshot_from_json( + "client", + "surface_client_manifest", + &json!({ + "schema_fingerprint": "sha256:aaa", + "protocol_fingerprint": "sha256:proto-1", + "commands": [{ "id": "todo.create" }] + }), + ) + .unwrap(); + let after = snapshot_from_json( + "client", + "surface_client_manifest", + &json!({ + "schema_fingerprint": "sha256:bbb", + "protocol_fingerprint": "sha256:proto-2", + "commands": [{ "id": "todo.create" }] + }), + ) + .unwrap(); + let classified = classify_snapshot_diff(&diff_snapshots(&before, &after)); + assert!(classified.iter().any(|c| { + c.path.contains("schema_fingerprint") + && c.decision == LifecycleDecision::AcceptManifestWire + })); + assert!(classified.iter().any(|c| { + c.path.contains("protocol_fingerprint") + && c.decision == LifecycleDecision::AcceptProtocolSemantic + && c.code == ContractDiagnosticCode::ProtocolDrift + })); + assert!(decisions_are_distinct(&classified)); + } +} diff --git a/distributed_cli/src/contracts/closeout.rs b/distributed_cli/src/contracts/closeout.rs new file mode 100644 index 00000000..22cf7ed1 --- /dev/null +++ b/distributed_cli/src/contracts/closeout.rs @@ -0,0 +1,121 @@ +//! CTL closeout helpers for non-deployment chain verification (task 8 slice). +//! +//! Deployment/render/XR closeout remains tasks 14–19 and is intentionally +//! out of scope here. + +use super::artifact::{ArtifactIdentity, ArtifactPredecessor, ContractArtifactKind}; +use super::chain::{check_predecessor_chain, ObservedPredecessor}; +use super::diagnostic::ContractCheckResult; +use super::program::{ClientProgramDescriptor, ProgramCompatibility}; + +/// Verify the local contract chain: application → plan → optional program. +pub fn close_local_contract_chain( + application: &ArtifactIdentity, + plan: &ArtifactIdentity, + plan_claims_application: bool, + program: Option<&ClientProgramDescriptor>, +) -> ContractCheckResult { + let mut observations = Vec::new(); + if plan_claims_application { + observations.push(ObservedPredecessor { + owner: "deployment-plan".into(), + source_path: "contracts/deployment-plan".into(), + claimed: ArtifactPredecessor { + entry_id: "application-manifest".into(), + identity: application.clone(), + }, + observed: Some(application.clone()), + }); + } + if let Some(program) = program { + observations.push(ObservedPredecessor { + owner: format!("program:{}", program.program_name), + source_path: "contracts/client-program".into(), + claimed: ArtifactPredecessor { + entry_id: "application-manifest".into(), + identity: program.application_manifest.clone(), + }, + observed: Some(application.clone()), + }); + observations.push(ObservedPredecessor { + owner: format!("program:{}", program.program_name), + source_path: "contracts/client-program".into(), + claimed: ArtifactPredecessor { + entry_id: "deployment-plan".into(), + identity: program.deployment_plan.clone(), + }, + observed: Some(plan.clone()), + }); + } + let _ = ContractArtifactKind::ApplicationManifest; + check_predecessor_chain(observations) +} + +/// Classify program compatibility when both descriptors validate. +pub fn classify_release_programs( + advertised: &ClientProgramDescriptor, + loaded: &ClientProgramDescriptor, +) -> Result { + advertised.classify_against(loaded) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::program::{ + ClientProgramArtifact, ClientProgramDescriptor, ClientProgramSurface, + }; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn local_chain_closeout_accepts_matching_predecessors() { + let app = ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + ); + let plan = ArtifactIdentity::new(ContractArtifactKind::DeploymentPlan, "sha256:plan"); + let result = close_local_contract_chain(&app, &plan, true, None); + assert!(result.diagnostics.is_empty()); + } + + #[test] + fn local_chain_closeout_reports_stale_program_plan() { + let root = std::env::temp_dir().join(format!( + "closeout-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.js"), b"1").unwrap(); + let app = ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + ); + let plan = ArtifactIdentity::new(ContractArtifactKind::DeploymentPlan, "sha256:plan"); + let program = ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:s".into(), + protocol_fingerprint: "sha256:p".into(), + }) + .artifact(ClientProgramArtifact { + path: "op.ts".into(), + digest: "sha256:o".into(), + }) + .application_manifest(app.clone()) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:stale-plan", + )) + .assets_from_dir(&root) + .unwrap() + .build() + .unwrap(); + let result = close_local_contract_chain(&app, &plan, true, Some(&program)); + assert!(!result.diagnostics.is_empty()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/distributed_cli/src/contracts/diagnostic.rs b/distributed_cli/src/contracts/diagnostic.rs new file mode 100644 index 00000000..69a78d53 --- /dev/null +++ b/distributed_cli/src/contracts/diagnostic.rs @@ -0,0 +1,545 @@ +use super::{ArtifactIdentity, ContractArtifactKind}; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +/// Stable diagnostic classifications shared by human and machine output. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub enum ContractDiagnosticCode { + #[serde(rename = "CTL-CATALOG-INVALID")] + CatalogInvalid, + #[serde(rename = "CTL-CATALOG-LIMIT")] + CatalogInputLimit, + #[serde(rename = "CTL-CATALOG-PATH")] + CatalogPath, + #[serde(rename = "CTL-CATALOG-SYMLINK")] + CatalogSymlinkEscape, + #[serde(rename = "CTL-CATALOG-FILE")] + CatalogSpecialFile, + #[serde(rename = "CTL-CATALOG-KIND")] + CatalogUnknownKind, + #[serde(rename = "CTL-CATALOG-DUPLICATE-SCOPE")] + CatalogDuplicateScope, + #[serde(rename = "CTL-CATALOG-DUPLICATE-OWNER")] + CatalogDuplicateOwner, + #[serde(rename = "CTL-CATALOG-DUPLICATE-OUTPUT")] + CatalogDuplicateOutput, + #[serde(rename = "CTL-CATALOG-GLOB")] + CatalogUnboundedGlob, + #[serde(rename = "CTL-ENV-VALUE")] + EnvironmentValue, + #[serde(rename = "CTL-CHAIN-MISSING")] + ChainMissingPredecessor, + #[serde(rename = "CTL-CHAIN-KIND")] + ChainKindMismatch, + #[serde(rename = "CTL-CHAIN-IDENTITY")] + ChainIdentityMismatch, + #[serde(rename = "CTL-CHAIN-CYCLE")] + ChainCycle, + #[serde(rename = "CTL-MIG-HISTORY")] + MigrationHistory, + #[serde(rename = "CTL-MIG-INVENTORY")] + MigrationInventory, + #[serde(rename = "CTL-SCHEMA-DRIFT")] + SchemaDrift, + #[serde(rename = "CTL-MANIFEST-VERSION")] + ManifestVersion, + #[serde(rename = "CTL-PROTOCOL-DRIFT")] + ProtocolDrift, + #[serde(rename = "CTL-GEN-STALE")] + GeneratedStale, + #[serde(rename = "CTL-PROGRAM-INCOMPLETE")] + ProgramIncomplete, + #[serde(rename = "CTL-CHAIN-STALE")] + ChainStale, +} + +impl ContractDiagnosticCode { + /// The stable code string used in logs and JSON output. + pub const fn as_str(self) -> &'static str { + match self { + Self::CatalogInvalid => "CTL-CATALOG-INVALID", + Self::CatalogInputLimit => "CTL-CATALOG-LIMIT", + Self::CatalogPath => "CTL-CATALOG-PATH", + Self::CatalogSymlinkEscape => "CTL-CATALOG-SYMLINK", + Self::CatalogSpecialFile => "CTL-CATALOG-FILE", + Self::CatalogUnknownKind => "CTL-CATALOG-KIND", + Self::CatalogDuplicateScope => "CTL-CATALOG-DUPLICATE-SCOPE", + Self::CatalogDuplicateOwner => "CTL-CATALOG-DUPLICATE-OWNER", + Self::CatalogDuplicateOutput => "CTL-CATALOG-DUPLICATE-OUTPUT", + Self::CatalogUnboundedGlob => "CTL-CATALOG-GLOB", + Self::EnvironmentValue => "CTL-ENV-VALUE", + Self::ChainMissingPredecessor => "CTL-CHAIN-MISSING", + Self::ChainKindMismatch => "CTL-CHAIN-KIND", + Self::ChainIdentityMismatch => "CTL-CHAIN-IDENTITY", + Self::ChainCycle => "CTL-CHAIN-CYCLE", + Self::MigrationHistory => "CTL-MIG-HISTORY", + Self::MigrationInventory => "CTL-MIG-INVENTORY", + Self::SchemaDrift => "CTL-SCHEMA-DRIFT", + Self::ManifestVersion => "CTL-MANIFEST-VERSION", + Self::ProtocolDrift => "CTL-PROTOCOL-DRIFT", + Self::GeneratedStale => "CTL-GEN-STALE", + Self::ProgramIncomplete => "CTL-PROGRAM-INCOMPLETE", + Self::ChainStale => "CTL-CHAIN-STALE", + } + } +} + +impl fmt::Display for ContractDiagnosticCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A diagnostic value that is redacted at construction and deserialization. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SafeDiagnosticValue(String); + +impl SafeDiagnosticValue { + /// Construct a value while removing credential-like material. + pub fn new(value: impl AsRef) -> Self { + Self(redact_value(value.as_ref())) + } + + /// Read the safely rendered value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SafeDiagnosticValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SafeDiagnosticValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(Self::new(value)) + } +} + +/// One stable, safely redacted contract diagnostic. +#[derive(Clone, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(deny_unknown_fields)] +pub struct ContractDiagnostic { + /// Stable classification code. + pub code: ContractDiagnosticCode, + /// Referenced artifact kind, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_kind: Option, + /// Declared scope, when known. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub scope: Option, + /// Authoritative owner of the affected contract. + #[serde(deserialize_with = "deserialize_redacted_string")] + pub owner: String, + /// Exact safe source paths involved in the diagnostic. + #[serde(default, deserialize_with = "deserialize_redacted_set")] + pub source_paths: BTreeSet, + /// Exact safe derived/output paths involved in the diagnostic. + #[serde(default, deserialize_with = "deserialize_redacted_set")] + pub derived_paths: BTreeSet, + /// Semantic path within an owner, if applicable. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub semantic_path: Option, + /// Safe expected value, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// Safe observed value, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed: Option, + /// Required lifecycle classification, if applicable. + #[serde( + default, + deserialize_with = "deserialize_redacted_option", + skip_serializing_if = "Option::is_none" + )] + pub required_classification: Option, + /// Whether merge-base evidence was available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_base_available: Option, + /// One safe repair or acceptance command. + #[serde(deserialize_with = "deserialize_redacted_string")] + pub repair_command: String, + /// Stable, non-sensitive explanatory detail. + #[serde( + default, + deserialize_with = "deserialize_redacted_string", + skip_serializing_if = "String::is_empty" + )] + pub detail: String, +} + +#[derive(Debug, Serialize)] +struct RedactedContractDiagnostic { + code: ContractDiagnosticCode, + #[serde(skip_serializing_if = "Option::is_none")] + artifact_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + owner: String, + source_paths: BTreeSet, + derived_paths: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + semantic_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + observed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + required_classification: Option, + #[serde(skip_serializing_if = "Option::is_none")] + merge_base_available: Option, + repair_command: String, + #[serde(skip_serializing_if = "String::is_empty")] + detail: String, +} + +impl ContractDiagnostic { + /// Build a diagnostic from safe facts. Values are redacted before storage. + #[expect(clippy::too_many_arguments)] + pub fn new( + code: ContractDiagnosticCode, + artifact_kind: Option, + scope: Option, + owner: impl AsRef, + source_paths: I, + derived_paths: J, + semantic_path: Option

, + expected: Option<&str>, + observed: Option<&str>, + required_classification: Option, + merge_base_available: Option, + repair_command: impl AsRef, + ) -> Self + where + S: AsRef, + I: IntoIterator, + I::Item: AsRef, + J: IntoIterator, + J::Item: AsRef, + P: AsRef, + Q: AsRef, + { + Self { + code, + artifact_kind, + scope: scope.map(|value| redact_value(value.as_ref())), + owner: redact_value(owner.as_ref()), + source_paths: source_paths + .into_iter() + .map(|value| redact_value(value.as_ref())) + .collect(), + derived_paths: derived_paths + .into_iter() + .map(|value| redact_value(value.as_ref())) + .collect(), + semantic_path: semantic_path.map(|value| redact_value(value.as_ref())), + expected: expected.map(SafeDiagnosticValue::new), + observed: observed.map(SafeDiagnosticValue::new), + required_classification: required_classification + .map(|value| redact_value(value.as_ref())), + merge_base_available, + repair_command: redact_value(repair_command.as_ref()), + detail: String::new(), + } + } + + /// Add a safe explanatory detail to an existing diagnostic. + pub fn with_detail(mut self, detail: impl AsRef) -> Self { + self.detail = redact_value(detail.as_ref()); + self + } + + /// Render the exact facts in the human-readable format. + pub fn human(&self) -> String { + self.redacted().human_unchecked() + } + + fn redacted(&self) -> Self { + Self { + code: self.code, + artifact_kind: self.artifact_kind, + scope: self.scope.as_deref().map(redact_value), + owner: redact_value(&self.owner), + source_paths: self + .source_paths + .iter() + .map(|value| redact_value(value)) + .collect(), + derived_paths: self + .derived_paths + .iter() + .map(|value| redact_value(value)) + .collect(), + semantic_path: self.semantic_path.as_deref().map(redact_value), + expected: self + .expected + .as_ref() + .map(|value| SafeDiagnosticValue::new(value.as_str())), + observed: self + .observed + .as_ref() + .map(|value| SafeDiagnosticValue::new(value.as_str())), + required_classification: self.required_classification.as_deref().map(redact_value), + merge_base_available: self.merge_base_available, + repair_command: redact_value(&self.repair_command), + detail: redact_value(&self.detail), + } + } + + fn human_unchecked(&self) -> String { + let mut output = self.code.to_string(); + if !self.detail.is_empty() { + output.push_str(": "); + output.push_str(&self.detail); + } + if let Some(kind) = self.artifact_kind { + output.push_str(&format!(" [kind={kind}]")); + } + if let Some(scope) = &self.scope { + output.push_str(&format!(" [scope={scope}]")); + } + output.push_str(&format!(" [owner={}]", self.owner)); + if !self.source_paths.is_empty() { + output.push_str(&format!(" [source={}]", join_set(&self.source_paths))); + } + if !self.derived_paths.is_empty() { + output.push_str(&format!(" [derived={}]", join_set(&self.derived_paths))); + } + if let Some(path) = &self.semantic_path { + output.push_str(&format!(" [semantic_path={path}]")); + } + if let Some(expected) = &self.expected { + output.push_str(&format!(" [expected={expected}]")); + } + if let Some(observed) = &self.observed { + output.push_str(&format!(" [observed={observed}]")); + } + if let Some(classification) = &self.required_classification { + output.push_str(&format!(" [classification={classification}]")); + } + if let Some(available) = self.merge_base_available { + output.push_str(&format!(" [merge_base_available={available}]")); + } + output.push_str(&format!(" [repair={}]", self.repair_command)); + output + } + + fn redacted_wire(&self) -> RedactedContractDiagnostic { + let redacted = self.redacted(); + RedactedContractDiagnostic { + code: redacted.code, + artifact_kind: redacted.artifact_kind, + scope: redacted.scope, + owner: redacted.owner, + source_paths: redacted.source_paths, + derived_paths: redacted.derived_paths, + semantic_path: redacted.semantic_path, + expected: redacted.expected, + observed: redacted.observed, + required_classification: redacted.required_classification, + merge_base_available: redacted.merge_base_available, + repair_command: redacted.repair_command, + detail: redacted.detail, + } + } + + /// Serialize the same safely redacted facts used by [`Self::human`]. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } +} + +impl Serialize for ContractDiagnostic { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.redacted_wire().serialize(serializer) + } +} + +impl fmt::Display for ContractDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.human()) + } +} + +impl fmt::Debug for ContractDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ContractDiagnostic") + .field("facts", &self.redacted_wire()) + .finish() + } +} + +/// Aggregated read-only contract-check output. +#[derive(Clone, Default, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ContractCheckResult { + /// Identity of the canonical catalog, when serialization succeeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_identity: Option, + /// Artifact identities collected by catalog entry ID. + #[serde(default)] + pub artifacts: BTreeMap, + /// Sorted independent diagnostics. + #[serde(default)] + pub diagnostics: BTreeSet, +} + +#[derive(Serialize)] +struct RedactedContractCheckResult { + #[serde(skip_serializing_if = "Option::is_none")] + catalog_identity: Option, + artifacts: BTreeMap, + diagnostics: BTreeSet, +} + +impl ContractCheckResult { + fn redacted_wire(&self) -> RedactedContractCheckResult { + RedactedContractCheckResult { + catalog_identity: self.catalog_identity.as_deref().map(redact_value), + artifacts: self + .artifacts + .iter() + .map(|(entry_id, identity)| { + ( + redact_value(entry_id), + ArtifactIdentity { + kind: identity.kind, + value: redact_value(&identity.value), + }, + ) + }) + .collect(), + diagnostics: self.diagnostics.clone(), + } + } +} + +impl Serialize for ContractCheckResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.redacted_wire().serialize(serializer) + } +} + +impl fmt::Debug for ContractCheckResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let redacted = self.redacted_wire(); + formatter + .debug_struct("ContractCheckResult") + .field("catalog_identity", &redacted.catalog_identity) + .field("artifacts", &redacted.artifacts) + .field("diagnostics", &redacted.diagnostics) + .finish() + } +} + +impl ContractCheckResult { + /// Whether no diagnostics were collected. + pub fn is_ok(&self) -> bool { + self.diagnostics.is_empty() + } + + /// Add one diagnostic while preserving deterministic order and uniqueness. + pub fn push(&mut self, diagnostic: ContractDiagnostic) { + self.diagnostics.insert(diagnostic); + } + + /// Canonical JSON bytes for stable comparisons and evidence. + pub fn canonical_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } + + /// Human output with diagnostics in stable order. + pub fn human(&self) -> String { + self.diagnostics + .iter() + .map(ContractDiagnostic::human) + .collect::>() + .join("\n") + } + + /// JSON output for machine consumers. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } +} + +impl fmt::Display for ContractCheckResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.human()) + } +} + +fn join_set(values: &BTreeSet) -> String { + values.iter().cloned().collect::>().join(",") +} + +fn deserialize_redacted_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + String::deserialize(deserializer).map(|value| redact_value(&value)) +} + +fn deserialize_redacted_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.map(|value| redact_value(&value))) +} + +fn deserialize_redacted_set<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + BTreeSet::::deserialize(deserializer).map(|values| { + values + .into_iter() + .map(|value| redact_value(&value)) + .collect() + }) +} + +fn redact_value(value: &str) -> String { + if is_secret_like(value) { + "[REDACTED]".to_string() + } else { + value.to_string() + } +} + +pub(crate) fn is_secret_like(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("postgres://") + || lower.contains("postgresql://") + || lower.contains("mysql://") + || lower.contains("mongodb://") + || lower.contains("bearer ") + || lower.contains("password=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("-----begin ") +} diff --git a/distributed_cli/src/contracts/migrations.rs b/distributed_cli/src/contracts/migrations.rs new file mode 100644 index 00000000..f542823a --- /dev/null +++ b/distributed_cli/src/contracts/migrations.rs @@ -0,0 +1,1513 @@ +//! Dialect-aware migration inventory and immutable-history checks. +//! +//! The inventory is deliberately a small, explicit data model. It owns the +//! logical migration order and the source paths/checksums for each supported +//! dialect; SQLx remains the runtime owner of applying those bytes. The +//! history checker reads the comparison inventory and its SQL bytes from one +//! explicit Git revision, so changing a local checksum cannot hide a baseline +//! edit. + +use super::diagnostic::is_secret_like; +use super::{ + ArtifactIdentity, ContractArtifactKind, ContractCheckResult, ContractDiagnostic, + ContractDiagnosticCode, ContractError, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// The current migration inventory wire format. +pub const MIGRATION_INVENTORY_SCHEMA_VERSION: u32 = 1; +/// Repository-relative location of the inventory. +pub const MIGRATION_INVENTORY_PATH: &str = "migrations/inventory.json"; +/// Maximum accepted inventory size. +pub const MAX_MIGRATION_INVENTORY_BYTES: usize = 1024 * 1024; +/// Maximum accepted SQL source size for one migration. +pub const MAX_MIGRATION_SQL_BYTES: usize = 4 * 1024 * 1024; +/// Maximum number of migrations in one inventory. +pub const MAX_MIGRATIONS: usize = 256; +/// Maximum nesting depth of JSON objects and arrays in an inventory. +pub const MAX_MIGRATION_JSON_DEPTH: usize = 24; +/// Maximum number of entries traversed beneath one dialect directory tree. +pub const MAX_MIGRATION_TOTAL_ENTRIES: usize = MAX_MIGRATIONS * 4; +/// Maximum number of direct entries beneath `migrations`. +pub const MAX_MIGRATION_TOP_LEVEL_ENTRIES: usize = 64; +/// The stable owner and scope used by migration diagnostics. +pub const MIGRATION_OWNER: &str = "distributed/migrations"; +pub const MIGRATION_SCOPE: &str = "repository/migrations"; + +const DIALECT_DIRECTORY_LIMIT: usize = 4_096; +const REDACTED_MIGRATION_PATH: &str = ""; + +/// A SQL dialect with an explicit migration directory. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MigrationDialect { + /// SQLite migrations. + Sqlite, + /// PostgreSQL migrations. + Postgres, +} + +impl MigrationDialect { + /// All dialects required by the repository contract. + pub const ALL: [Self; 2] = [Self::Sqlite, Self::Postgres]; + + /// Stable inventory spelling. + pub const fn as_str(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } + + fn directory(self) -> &'static str { + match self { + Self::Sqlite => "migrations/sqlite", + Self::Postgres => "migrations/postgres", + } + } +} + +impl std::fmt::Display for MigrationDialect { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One dialect-specific SQL file declaration. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationFile { + /// Repository-relative path to the SQL file. + pub path: String, + /// Lowercase SHA-256 digest of the exact file bytes. + pub sha256: String, +} + +/// One logical migration and its required dialect implementations. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationEntry { + /// Consecutive logical migration version, starting at one. + pub version: u64, + /// Human-readable SQLx migration description. + pub description: String, + /// SQLite source declaration. + pub sqlite: MigrationFile, + /// PostgreSQL source declaration. + pub postgres: MigrationFile, +} + +impl MigrationEntry { + /// Return the declaration for one supported dialect. + pub fn file(&self, dialect: MigrationDialect) -> &MigrationFile { + match dialect { + MigrationDialect::Sqlite => &self.sqlite, + MigrationDialect::Postgres => &self.postgres, + } + } +} + +/// The single source of migration registration and history identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationInventory { + /// Inventory wire-format version. + pub schema_version: u32, + /// Migrations in their runtime application order. + pub migrations: Vec, +} + +impl MigrationInventory { + /// Parse and structurally validate inventory JSON without filesystem I/O. + pub fn from_json_str(input: &str) -> Result { + if input.len() > MAX_MIGRATION_INVENTORY_BYTES { + return Err(inventory_error(format!( + "migration inventory is {} bytes; maximum supported size is {MAX_MIGRATION_INVENTORY_BYTES}", + input.len() + ))); + } + validate_json_nesting(input)?; + let value: Value = serde_json::from_str(input) + .map_err(|error| inventory_error(format!("parse migration inventory JSON: {error}")))?; + validate_json_value(&value, 1)?; + let inventory: Self = serde_json::from_value(value) + .map_err(|error| inventory_error(format!("parse migration inventory JSON: {error}")))?; + inventory.validate_structure()?; + Ok(inventory) + } + + /// Alias for [`Self::from_json_str`]. + pub fn parse(input: &str) -> Result { + Self::from_json_str(input) + } + + /// Read and fully validate an inventory at its conventional repository path. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let repository_root = inferred_repository_root(path); + let canonical_root = canonical_repository_root(&repository_root)?; + let relative = relative_path(&repository_root, path)?; + reject_symlink_components(&canonical_root, &relative, "migration inventory")?; + let bytes = read_bounded_file(path, MAX_MIGRATION_INVENTORY_BYTES, "migration inventory")?; + let input = std::str::from_utf8(&bytes) + .map_err(|_| inventory_error("migration inventory is not UTF-8".to_string()))?; + let inventory = Self::from_json_str(input)?; + inventory.validate_paths(&canonical_root)?; + Ok(inventory) + } + + /// Load and validate `migrations/inventory.json` beneath a repository root. + pub fn from_repository_root(root: impl AsRef) -> Result { + Self::from_path(root.as_ref().join(MIGRATION_INVENTORY_PATH)) + } + + /// Serialize the validated inventory without changing its runtime order. + pub fn canonical_bytes(&self) -> Result, ContractError> { + self.validate_structure()?; + serde_json::to_vec(self) + .map_err(|error| inventory_error(format!("serialize migration inventory: {error}"))) + } + + /// Validate all declared files, checksums, dialect directories, and extras. + pub fn validate_paths(&self, root: impl AsRef) -> Result<(), ContractError> { + self.validate_structure()?; + let root = canonical_repository_root(root.as_ref())?; + let declared = self + .migrations + .iter() + .flat_map(|migration| { + MigrationDialect::ALL + .into_iter() + .map(move |dialect| (dialect, migration.file(dialect).path.clone())) + }) + .collect::>(); + + for migration in &self.migrations { + for dialect in MigrationDialect::ALL { + let declaration = migration.file(dialect); + let display_path = declared_path_display(&declaration.path); + let bytes = read_migration_file(&root, declaration, dialect)?; + let observed = sha256_hex(&bytes); + if observed != declaration.sha256 { + return Err(inventory_error(format!( + "{} migration `{}` checksum mismatch: expected {}, observed {}", + dialect, display_path, declaration.sha256, observed + ))); + } + if std::str::from_utf8(&bytes).is_err() { + return Err(inventory_error(format!( + "{} migration `{display_path}` is not UTF-8 SQL", + dialect + ))); + } + } + } + + for dialect in MigrationDialect::ALL { + let actual = collect_sql_files(&root, dialect)?; + let declared_for_dialect = declared + .iter() + .filter(|(declared_dialect, _)| *declared_dialect == dialect) + .cloned() + .collect::>(); + if let Some(path) = actual.difference(&declared_for_dialect).next() { + let display_path = declared_path_display(&path.1); + return Err(inventory_error(format!( + "extra {} migration file `{}` is not registered", + dialect, display_path + ))); + } + } + validate_no_extra_dialect_directories(&root)?; + Ok(()) + } + + /// Collect a read-only current-tree result with stable diagnostics. + pub fn check(&self, root: impl AsRef) -> ContractCheckResult { + let mut result = ContractCheckResult::default(); + let identity = match self.canonical_bytes() { + Ok(identity) => identity, + Err(error) => { + result.push(diagnostic_for_error(&error)); + return result; + } + }; + result.catalog_identity = Some(super::artifact::canonical_digest(&identity)); + result.artifacts.insert( + "migration-inventory".to_string(), + ArtifactIdentity::from_canonical_bytes( + ContractArtifactKind::MigrationInventory, + &identity, + ), + ); + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + result + } + + /// Compare this current inventory against inventory and SQL bytes at a Git revision. + pub fn check_history( + &self, + root: impl AsRef, + base_revision: &str, + ) -> MigrationHistoryCheck { + let root = root.as_ref(); + let mut result = MigrationHistoryCheck { + baseline: BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "comparison has not started".to_string(), + }, + diagnostics: BTreeSet::new(), + }; + + if let Err(error) = self.validate_paths(root) { + result.push(diagnostic_for_error(&error)); + } + + let Ok(root) = canonical_repository_root(root) else { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "repository root is unavailable".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "repository root is unavailable", + false, + )); + return result; + }; + if !valid_revision(base_revision) || !git_revision_exists(&root, base_revision) { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "explicit Git revision is unavailable".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "immutable migration history evidence is unavailable for the explicit base revision", + false, + )); + return result; + } + + let baseline_bytes = match git_file(&root, base_revision, MIGRATION_INVENTORY_PATH) { + Ok(bytes) => bytes, + Err(reason) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason, + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision has no readable migration inventory", + true, + )); + return result; + } + }; + let baseline_input = match std::str::from_utf8(&baseline_bytes) { + Ok(input) => input, + Err(_) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "baseline migration inventory is not UTF-8".to_string(), + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision contains a non-UTF-8 migration inventory", + true, + )); + return result; + } + }; + let baseline = match Self::from_json_str(baseline_input) { + Ok(inventory) => inventory, + Err(error) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "baseline migration inventory is structurally invalid".to_string(), + }; + result.push( + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + Some(base_revision), + Some(error.message()), + Some("restore the baseline inventory and add a new migration"), + Some(true), + "restore the baseline inventory and add a new migration", + ) + .with_detail("baseline migration inventory is structurally invalid"), + ); + return result; + } + }; + + result.baseline = BaselineAvailability::Available { + revision: base_revision.to_string(), + }; + let baseline_files = match load_baseline_files(&root, base_revision, &baseline) { + Ok(files) => files, + Err(reason) => { + result.baseline = BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason, + }; + result.push(unavailable_diagnostic( + base_revision, + "the explicit Git revision has incomplete migration SQL evidence", + true, + )); + return result; + } + }; + compare_history( + self, + &baseline, + &baseline_files, + &root, + base_revision, + &mut result, + ); + result + } + + /// Alias emphasizing that the baseline comparison is read-only. + pub fn compare_history( + &self, + root: impl AsRef, + base_revision: &str, + ) -> MigrationHistoryCheck { + self.check_history(root, base_revision) + } + + fn validate_structure(&self) -> Result<(), ContractError> { + if self.schema_version != MIGRATION_INVENTORY_SCHEMA_VERSION { + return Err(inventory_error(format!( + "unsupported migration inventory schema version {}; expected {}", + self.schema_version, MIGRATION_INVENTORY_SCHEMA_VERSION + ))); + } + if self.migrations.is_empty() || self.migrations.len() > MAX_MIGRATIONS { + return Err(inventory_error(format!( + "migration inventory must contain 1..={MAX_MIGRATIONS} migrations" + ))); + } + + let mut paths = BTreeMap::::new(); + for (index, migration) in self.migrations.iter().enumerate() { + let expected_version = (index + 1) as u64; + if migration.version != expected_version { + return Err(inventory_error(format!( + "migration versions must be ordered and consecutive: expected {expected_version}, observed {}", + migration.version + ))); + } + if migration.version > i64::MAX as u64 { + return Err(inventory_error(format!( + "migration version {} exceeds SQLx's signed version range", + migration.version + ))); + } + validate_description(&migration.description, migration.version)?; + for dialect in MigrationDialect::ALL { + let file = migration.file(dialect); + validate_migration_path(&file.path, dialect)?; + validate_checksum(&file.sha256, &file.path)?; + if let Some((previous_version, previous_dialect)) = paths.get(&file.path) { + return Err(inventory_error(format!( + "{} migration path `{}` is declared more than once ({} version {}, {} version {})", + dialect, + declared_path_display(&file.path), + previous_dialect, + previous_version, + dialect, + migration.version + ))); + } + paths.insert(file.path.clone(), (migration.version, dialect)); + } + } + Ok(()) + } +} + +/// The result of comparing an inventory with an explicit Git baseline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MigrationHistoryCheck { + /// Whether the explicit revision was available and readable. + pub baseline: BaselineAvailability, + /// Deterministically ordered history and current-tree diagnostics. + pub diagnostics: BTreeSet, +} + +impl MigrationHistoryCheck { + /// True only when baseline evidence exists and no diagnostic was emitted. + pub fn is_verified(&self) -> bool { + self.baseline.is_available() && self.diagnostics.is_empty() + } + + /// Alias for [`Self::is_verified`]. Unavailable evidence is never success. + pub fn is_ok(&self) -> bool { + self.is_verified() + } + + /// Whether the explicit comparison baseline could not be read. + pub fn is_unavailable(&self) -> bool { + !self.baseline.is_available() + } + + /// Add one deterministic diagnostic. + pub fn push(&mut self, diagnostic: ContractDiagnostic) { + self.diagnostics.insert(diagnostic); + } + + /// Human output shared with aggregate contract checks. + pub fn human(&self) -> String { + self.diagnostics + .iter() + .map(ContractDiagnostic::human) + .collect::>() + .join("\n") + } + + /// JSON output shared with aggregate contract checks. + pub fn to_json(&self) -> Result { + let mut result = ContractCheckResult::default(); + for diagnostic in &self.diagnostics { + result.push(diagnostic.clone()); + } + result.to_json() + } +} + +/// Typed fact describing whether the comparison revision was available. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BaselineAvailability { + /// Inventory and all declared SQL bytes were read from this revision. + Available { revision: String }, + /// Comparison evidence was unavailable; this is not a successful check. + Unavailable { revision: String, reason: String }, +} + +impl BaselineAvailability { + /// Whether baseline inventory and SQL evidence was available. + pub fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } + + /// The requested revision, regardless of availability. + pub fn revision(&self) -> &str { + match self { + Self::Available { revision } | Self::Unavailable { revision, .. } => revision, + } + } + + /// Unavailability explanation, if any. + pub fn reason(&self) -> Option<&str> { + match self { + Self::Available { .. } => None, + Self::Unavailable { reason, .. } => Some(reason), + } + } +} + +/// Compare a repository's conventional inventory against an explicit Git revision. +pub fn check_migration_history( + root: impl AsRef, + base_revision: &str, +) -> MigrationHistoryCheck { + let root = root.as_ref(); + match MigrationInventory::from_repository_root(root) { + Ok(inventory) => inventory.check_history(root, base_revision), + Err(error) => { + let mut result = MigrationHistoryCheck { + baseline: BaselineAvailability::Unavailable { + revision: base_revision.to_string(), + reason: "current migration inventory is invalid".to_string(), + }, + diagnostics: BTreeSet::new(), + }; + result.push(diagnostic_for_error(&error)); + result + } + } +} + +/// Validate a repository's conventional inventory and SQL files. +pub fn check_migration_inventory(root: impl AsRef) -> ContractCheckResult { + match MigrationInventory::from_repository_root(root.as_ref()) { + Ok(inventory) => inventory.check(root), + Err(error) => { + let mut result = ContractCheckResult::default(); + result.push(diagnostic_for_error(&error)); + result + } + } +} + +fn compare_history( + current: &MigrationInventory, + baseline: &MigrationInventory, + baseline_files: &BTreeMap<(u64, MigrationDialect), Vec>, + root: &Path, + base_revision: &str, + result: &mut MigrationHistoryCheck, +) { + let current_by_version = current + .migrations + .iter() + .map(|migration| (migration.version, migration)) + .collect::>(); + let current_last = current + .migrations + .last() + .map_or(0, |migration| migration.version); + let baseline_last = baseline + .migrations + .last() + .map_or(0, |migration| migration.version); + let next_version = current_last.max(baseline_last).saturating_add(1); + + let baseline_versions = baseline + .migrations + .iter() + .map(|migration| migration.version) + .collect::>(); + let current_baseline_versions = current + .migrations + .iter() + .filter(|migration| baseline_versions.contains(&migration.version)) + .map(|migration| migration.version) + .collect::>(); + if current_baseline_versions != baseline_versions { + result.push(history_diagnostic( + base_revision, + next_version, + baseline + .migrations + .iter() + .flat_map(|migration| { + MigrationDialect::ALL.map(|dialect| migration.file(dialect).path.clone()) + }) + .collect::>(), + Some(&format!("versions={baseline_versions:?}")), + Some(&format!("versions={current_baseline_versions:?}")), + "baseline migration order or numbering changed", + )); + } + + for baseline_migration in &baseline.migrations { + let Some(current_migration) = current_by_version.get(&baseline_migration.version) else { + result.push(history_diagnostic( + base_revision, + next_version, + MigrationDialect::ALL.map(|dialect| baseline_migration.file(dialect).path.clone()), + Some(&format!( + "version {} is present", + baseline_migration.version + )), + Some("missing"), + &format!( + "baseline migration {} was deleted; restore it and add migration {}", + baseline_migration.version, next_version + ), + )); + continue; + }; + + if current_migration.description != baseline_migration.description { + result.push(history_diagnostic( + base_revision, + next_version, + MigrationDialect::ALL.map(|dialect| current_migration.file(dialect).path.clone()), + Some(&baseline_migration.description), + Some(¤t_migration.description), + &format!( + "baseline migration {} description changed; restore it and add migration {}", + baseline_migration.version, next_version + ), + )); + } + + for dialect in MigrationDialect::ALL { + let baseline_file = baseline_migration.file(dialect); + let current_file = current_migration.file(dialect); + if baseline_file.path != current_file.path { + let expected_path = declared_path_display(&baseline_file.path); + let observed_path = declared_path_display(¤t_file.path); + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&expected_path), + Some(&observed_path), + &format!( + "baseline migration {} {} path changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + if baseline_file.sha256 != current_file.sha256 { + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&baseline_file.sha256), + Some(¤t_file.sha256), + &format!( + "baseline migration {} {} checksum changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + let Some(baseline_bytes) = baseline_files.get(&(baseline_migration.version, dialect)) + else { + continue; + }; + let Ok(current_bytes) = read_migration_file(root, current_file, dialect) else { + continue; + }; + if baseline_bytes != ¤t_bytes { + let baseline_hash = sha256_hex(baseline_bytes); + let current_hash = sha256_hex(¤t_bytes); + result.push(history_diagnostic( + base_revision, + next_version, + [baseline_file.path.clone(), current_file.path.clone()], + Some(&baseline_hash), + Some(¤t_hash), + &format!( + "baseline migration {} {} SQL bytes changed; restore it and add migration {}", + baseline_migration.version, dialect, next_version + ), + )); + } + } + } +} + +fn load_baseline_files( + root: &Path, + revision: &str, + inventory: &MigrationInventory, +) -> Result>, String> { + let mut files = BTreeMap::new(); + for migration in &inventory.migrations { + for dialect in MigrationDialect::ALL { + let declaration = migration.file(dialect); + let display_path = declared_path_display(&declaration.path); + let bytes = git_file(root, revision, &declaration.path).map_err(|reason| { + format!( + "unable to read baseline {} migration `{}`: {reason}", + dialect, display_path + ) + })?; + let observed = sha256_hex(&bytes); + if observed != declaration.sha256 { + return Err(format!( + "baseline {} migration `{}` checksum does not match its inventory", + dialect, display_path + )); + } + if bytes.len() > MAX_MIGRATION_SQL_BYTES || std::str::from_utf8(&bytes).is_err() { + return Err(format!( + "baseline {} migration `{}` is not bounded UTF-8 SQL", + dialect, display_path + )); + } + files.insert((migration.version, dialect), bytes); + } + } + Ok(files) +} + +fn history_diagnostic( + base_revision: &str, + next_version: u64, + paths: I, + expected: Option<&str>, + observed: Option<&str>, + detail: &str, +) -> ContractDiagnostic +where + I: IntoIterator, + P: AsRef, +{ + let repair = format!( + "restore the baseline migration from {base_revision} and add migration {next_version}" + ); + let paths = paths + .into_iter() + .map(|path| declared_path_display(path.as_ref())) + .collect::>(); + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + paths, + [MIGRATION_INVENTORY_PATH], + None::<&str>, + expected, + observed, + Some(format!("add migration {next_version}")), + Some(true), + repair, + ) + .with_detail(detail) +} + +fn unavailable_diagnostic( + base_revision: &str, + detail: &str, + merge_base_available: bool, +) -> ContractDiagnostic { + ContractDiagnostic::new( + ContractDiagnosticCode::MigrationHistory, + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + Some(base_revision), + Some("unavailable"), + Some("history evidence unavailable"), + Some(merge_base_available), + "rerun with `distributed contracts check --base `", + ) + .with_detail(detail) +} + +fn diagnostic_for_error(error: &ContractError) -> ContractDiagnostic { + ContractDiagnostic::new( + error.code(), + Some(ContractArtifactKind::MigrationInventory), + Some(MIGRATION_SCOPE), + MIGRATION_OWNER, + [MIGRATION_INVENTORY_PATH], + std::iter::empty::<&str>(), + None::<&str>, + None, + None, + None::<&str>, + None, + "inspect migrations/inventory.json and declared SQL files", + ) + .with_detail(error.message()) +} + +fn inventory_error(message: String) -> ContractError { + ContractError::new(ContractDiagnosticCode::MigrationInventory, message) +} + +fn validate_json_nesting(input: &str) -> Result<(), ContractError> { + let mut depth = 0usize; + let mut escaped = false; + let mut in_string = false; + + for byte in input.bytes() { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + + match byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth = depth.saturating_add(1); + if depth > MAX_MIGRATION_JSON_DEPTH { + return Err(inventory_error( + "migration inventory exceeds maximum JSON nesting depth".to_string(), + )); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + + Ok(()) +} + +fn validate_json_value(value: &Value, depth: usize) -> Result<(), ContractError> { + if depth > MAX_MIGRATION_JSON_DEPTH { + return Err(inventory_error( + "migration inventory exceeds maximum JSON nesting depth".to_string(), + )); + } + match value { + Value::Object(object) => { + for (key, child) in object { + if is_secret_like(key) { + return Err(inventory_error(format!( + "migration inventory contains a credential-like field `{key}`" + ))); + } + let child_depth = if child.is_object() || child.is_array() { + depth + 1 + } else { + depth + }; + validate_json_value(child, child_depth)?; + } + } + Value::Array(array) => { + if array.len() > MAX_MIGRATIONS * 4 { + return Err(inventory_error( + "migration inventory contains too many JSON array values".to_string(), + )); + } + for child in array { + let child_depth = if child.is_object() || child.is_array() { + depth + 1 + } else { + depth + }; + validate_json_value(child, child_depth)?; + } + } + Value::String(string) => { + if string.len() > 4 * 1024 { + return Err(inventory_error( + "migration inventory contains an oversized string".to_string(), + )); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +fn validate_description(description: &str, version: u64) -> Result<(), ContractError> { + if description.is_empty() + || description.trim() != description + || description.len() > 4 * 1024 + || description.contains('\0') + || is_secret_like(description) + { + return Err(inventory_error(format!( + "migration {version} description is empty, sensitive, or not portable" + ))); + } + Ok(()) +} + +fn validate_checksum(checksum: &str, path: &str) -> Result<(), ContractError> { + if checksum.len() != 64 + || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) + || checksum + .chars() + .any(|character| character.is_ascii_uppercase()) + { + return Err(inventory_error(format!( + "migration `{}` must declare one lowercase 64-character SHA-256 checksum", + declared_path_display(path) + ))); + } + Ok(()) +} + +fn validate_migration_path(path: &str, dialect: MigrationDialect) -> Result<(), ContractError> { + let display_path = declared_path_display(path); + if path.is_empty() + || path.trim() != path + || path.len() > 4 * 1024 + || path.contains('\0') + || path.contains('\\') + || !path.ends_with(".sql") + { + return Err(inventory_error(format!( + "{} migration path `{display_path}` is not a portable SQL path", + dialect, + ))); + } + let path_value = Path::new(path); + if path_value.is_absolute() + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !path_value.starts_with(dialect.directory()) + { + return Err(inventory_error(format!( + "{} migration path `{display_path}` must remain beneath `{}`", + dialect, + dialect.directory() + ))); + } + if is_secret_like(path) { + return Err(inventory_error(format!( + "{} migration path `{display_path}` contains sensitive material", + dialect, + ))); + } + Ok(()) +} + +fn declared_path_display(path: &str) -> String { + let path_value = Path::new(path); + if is_secret_like(path) + || path_value.is_absolute() + || path.contains('\\') + || path_value + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + REDACTED_MIGRATION_PATH.to_string() + } else { + path.to_string() + } +} + +fn canonical_repository_root(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root) + .map_err(|error| inventory_error(format!("inspect migration repository root: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "migration repository root must not be a symlink".to_string(), + )); + } + let root = fs::canonicalize(root) + .map_err(|error| inventory_error(format!("resolve migration repository root: {error}")))?; + let metadata = fs::metadata(&root) + .map_err(|error| inventory_error(format!("inspect migration repository root: {error}")))?; + if !metadata.is_dir() { + return Err(inventory_error( + "migration repository root is not a directory".to_string(), + )); + } + Ok(root) +} + +fn relative_path(root: &Path, path: &Path) -> Result { + let current_directory = std::env::current_dir() + .map_err(|error| inventory_error(format!("resolve current directory: {error}")))?; + let absolute_root = if root.is_absolute() { + root.to_path_buf() + } else { + current_directory.join(root) + }; + let absolute_path = if path.is_absolute() { + path.to_path_buf() + } else { + current_directory.join(path) + }; + absolute_path + .strip_prefix(&absolute_root) + .map(Path::to_path_buf) + .map_err(|_| { + inventory_error("migration inventory path escaped repository root".to_string()) + }) +} + +fn inferred_repository_root(path: &Path) -> PathBuf { + if path.file_name().and_then(|name| name.to_str()) == Some("inventory.json") + && path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some("migrations") + { + return path + .parent() + .and_then(Path::parent) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + } + path.parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) +} + +fn read_migration_file( + root: &Path, + declaration: &MigrationFile, + dialect: MigrationDialect, +) -> Result, ContractError> { + let path = root.join(&declaration.path); + let display_path = declared_path_display(&declaration.path); + let mut current = root.to_path_buf(); + let components = Path::new(&declaration.path) + .components() + .collect::>(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + return Err(inventory_error(format!( + "{} migration path `{}` contains traversal", + dialect, display_path + ))); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + inventory_error(format!( + "missing {} migration file `{}`", + dialect, display_path + )) + } else { + inventory_error(format!( + "inspect {} migration file `{}`: {error}", + dialect, display_path + )) + } + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration file `{}` must not be a symlink", + dialect, display_path + ), + )); + } + if index + 1 != components.len() && !metadata.is_dir() { + return Err(inventory_error(format!( + "{} migration path `{}` has a non-directory parent", + dialect, display_path + ))); + } + } + let metadata = fs::metadata(&path).map_err(|error| { + inventory_error(format!( + "inspect {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if !metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "{} migration file `{}` is not regular", + dialect, display_path + )), + ); + } + let file = File::open(&path).map_err(|error| { + inventory_error(format!( + "open {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + let opened_metadata = file.metadata().map_err(|error| { + inventory_error(format!( + "inspect opened {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if !opened_metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "opened {} migration file `{}` is not regular", + dialect, display_path + )), + ); + } + if opened_metadata.len() > MAX_MIGRATION_SQL_BYTES as u64 { + return Err(inventory_error(format!( + "{} migration file `{}` exceeds {MAX_MIGRATION_SQL_BYTES} bytes", + dialect, display_path + ))); + } + let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); + file.take(MAX_MIGRATION_SQL_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + inventory_error(format!( + "read {} migration file `{}`: {error}", + dialect, display_path + )) + })?; + if bytes.len() > MAX_MIGRATION_SQL_BYTES { + return Err(inventory_error(format!( + "{} migration file `{}` exceeds {MAX_MIGRATION_SQL_BYTES} bytes", + dialect, display_path + ))); + } + Ok(bytes) +} + +fn relative_path_display(root: &Path, path: &Path) -> String { + let relative = path + .strip_prefix(root) + .map(|relative| { + relative + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }) + .unwrap_or_else(|_| "".to_string()); + declared_path_display(&relative) +} + +fn collect_sql_files( + root: &Path, + dialect: MigrationDialect, +) -> Result, ContractError> { + let directory = root.join(dialect.directory()); + let mut result = BTreeSet::new(); + let mut pending = vec![directory]; + let mut directories = 0; + let mut entries_seen = 0usize; + while let Some(directory) = pending.pop() { + directories += 1; + if directories > DIALECT_DIRECTORY_LIMIT { + return Err(inventory_error(format!( + "{} migration directory tree exceeds {DIALECT_DIRECTORY_LIMIT} directories", + dialect + ))); + } + let directory_metadata = fs::symlink_metadata(&directory).map_err(|error| { + inventory_error(format!( + "inspect {} migration directory `{}`: {error}", + dialect, + relative_path_display(root, &directory) + )) + })?; + if directory_metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration directory `{}` must not be a symlink", + dialect, + relative_path_display(root, &directory) + ), + )); + } + if !directory_metadata.is_dir() { + return Err(inventory_error(format!( + "{} migration directory `{}` is not a directory", + dialect, + relative_path_display(root, &directory) + ))); + } + let mut read_entries = fs::read_dir(&directory).map_err(|error| { + inventory_error(format!( + "read {} migration directory `{}`: {error}", + dialect, + relative_path_display(root, &directory) + )) + })?; + let remaining_entries = MAX_MIGRATION_TOTAL_ENTRIES - entries_seen; + let mut entries = Vec::with_capacity(remaining_entries); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_MIGRATION_TOTAL_ENTRIES { + return Err(inventory_error(format!( + "{} migration directory tree exceeds {MAX_MIGRATION_TOTAL_ENTRIES} entries", + dialect + ))); + } + entries_seen += 1; + entries.push(entry.map_err(|error| { + inventory_error(format!("read {dialect} migration directory entry: {error}")) + })?); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + inventory_error(format!( + "inspect {dialect} migration directory entry `{}`: {error}", + relative_path_display(root, &path) + )) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{} migration path `{}` must not be a symlink", + dialect, + relative_path_display(root, &path) + ), + )); + } + if metadata.is_dir() { + pending.push(path); + continue; + } + if !metadata.is_file() { + return Err( + ContractDiagnosticCode::CatalogSpecialFile.into_error(format!( + "{} migration path `{}` is not regular", + dialect, + relative_path_display(root, &path) + )), + ); + } + if path.extension().and_then(|extension| extension.to_str()) != Some("sql") { + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| inventory_error("migration path escaped repository root".to_string()))? + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + result.insert((dialect, relative)); + if result.len() > MAX_MIGRATIONS * 2 { + return Err(inventory_error( + "migration directory contains too many SQL files".to_string(), + )); + } + } + } + Ok(result) +} + +fn validate_no_extra_dialect_directories(root: &Path) -> Result<(), ContractError> { + let migrations = root.join("migrations"); + let migrations_metadata = fs::symlink_metadata(&migrations) + .map_err(|error| inventory_error(format!("inspect migrations directory: {error}")))?; + if migrations_metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + "migrations directory must not be a symlink".to_string(), + )); + } + if !migrations_metadata.is_dir() { + return Err(inventory_error( + "migrations path is not a directory".to_string(), + )); + } + let mut read_entries = fs::read_dir(&migrations) + .map_err(|error| inventory_error(format!("read migrations directory: {error}")))?; + let mut entries_seen = 0usize; + let mut entries = Vec::with_capacity(MAX_MIGRATION_TOP_LEVEL_ENTRIES); + loop { + let Some(entry) = read_entries.next() else { + break; + }; + if entries_seen >= MAX_MIGRATION_TOP_LEVEL_ENTRIES { + return Err(inventory_error(format!( + "migrations directory exceeds {MAX_MIGRATION_TOP_LEVEL_ENTRIES} entries" + ))); + } + entries_seen += 1; + entries.push( + entry.map_err(|error| inventory_error(format!("read migrations entry: {error}")))?, + ); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| inventory_error(format!("inspect migrations entry: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "migration path `{}` must not be a symlink", + relative_path_display(root, &path) + ), + )); + } + if !metadata.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!(name, "sqlite" | "postgres") { + return Err(inventory_error(format!( + "unsupported migration dialect directory `{}`", + relative_path_display(root, &path) + ))); + } + } + Ok(()) +} + +fn reject_symlink_components( + root: &Path, + relative: &Path, + label: &str, +) -> Result<(), ContractError> { + let display_path = declared_path_display(&relative.to_string_lossy()); + let mut current = root.to_path_buf(); + for component in relative.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + return Err(inventory_error(format!( + "{label} path `{}` contains parent traversal", + display_path + ))); + } + Component::Normal(part) => current.push(part), + Component::Prefix(_) | Component::RootDir => { + return Err(inventory_error(format!( + "{label} path `{}` is not relative to the repository root", + display_path + ))); + } + } + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + inventory_error(format!("inspect {label} path `{}`: {error}", display_path)) + })?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!( + "{label} path `{}` must not traverse a symlink", + display_path + ), + )); + } + } + Ok(()) +} + +fn read_bounded_file(path: &Path, limit: usize, label: &str) -> Result, ContractError> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| inventory_error(format!("read {label}: {error}")))?; + if metadata.file_type().is_symlink() { + return Err(ContractError::new( + ContractDiagnosticCode::CatalogSymlinkEscape, + format!("{label} must not be a symlink"), + )); + } + if !metadata.is_file() { + return Err(ContractDiagnosticCode::CatalogSpecialFile + .into_error(format!("{label} is not a regular file"))); + } + if metadata.len() > limit as u64 { + return Err(inventory_error(format!( + "{label} is {} bytes; maximum supported size is {limit}", + metadata.len() + ))); + } + let file = + File::open(path).map_err(|error| inventory_error(format!("read {label}: {error}")))?; + let opened_size = file + .metadata() + .map_err(|error| inventory_error(format!("inspect opened {label}: {error}")))?; + if !opened_size.is_file() { + return Err(ContractDiagnosticCode::CatalogSpecialFile + .into_error(format!("opened {label} is not a regular file"))); + } + let opened_size = opened_size.len(); + if opened_size > limit as u64 { + return Err(inventory_error(format!( + "opened {label} is {opened_size} bytes; maximum supported size is {limit}" + ))); + } + let mut bytes = Vec::with_capacity(opened_size as usize); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| inventory_error(format!("read {label}: {error}")))?; + if bytes.len() > limit { + return Err(inventory_error(format!("{label} exceeds {limit} bytes"))); + } + Ok(bytes) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn valid_revision(revision: &str) -> bool { + !revision.is_empty() + && revision.trim() == revision + && !revision.starts_with('-') + && !revision.contains(':') + && !revision.chars().any(char::is_control) + && !revision.chars().any(char::is_whitespace) +} + +fn git_revision_exists(root: &Path, revision: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--verify", "--quiet", "--end-of-options"]) + .arg(format!("{revision}^{{commit}}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn git_file(root: &Path, revision: &str, path: &str) -> Result, String> { + let object = format!("{revision}:{path}"); + let limit = if path == MIGRATION_INVENTORY_PATH { + MAX_MIGRATION_INVENTORY_BYTES + } else { + MAX_MIGRATION_SQL_BYTES + }; + let mut child = Command::new("git") + .arg("-C") + .arg(root) + .args(["show", "--no-ext-diff", "--format=", "--end-of-options"]) + .arg(object) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "could not invoke Git for the explicit revision".to_string())?; + let mut stdout = child.stdout.take().ok_or_else(|| { + terminate_child(&mut child); + "Git did not provide a readable baseline stream".to_string() + })?; + let mut bytes = Vec::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + match stdout.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + if read > limit.saturating_sub(bytes.len()) { + terminate_child(&mut child); + return Err(format!("baseline file exceeds {limit} bytes")); + } + bytes.extend_from_slice(&buffer[..read]); + } + Err(_) => { + terminate_child(&mut child); + return Err("could not read the explicit Git baseline file".to_string()); + } + } + } + drop(stdout); + let status = child + .wait() + .map_err(|_| "could not wait for Git baseline file".to_string())?; + if !status.success() { + return Err("Git did not provide the requested baseline file".to_string()); + } + Ok(bytes) +} + +fn terminate_child(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +trait DiagnosticCodeError { + fn into_error(self, message: String) -> ContractError; +} + +impl DiagnosticCodeError for ContractDiagnosticCode { + fn into_error(self, message: String) -> ContractError { + ContractError::new(self, message) + } +} diff --git a/distributed_cli/src/contracts/mod.rs b/distributed_cli/src/contracts/mod.rs new file mode 100644 index 00000000..68f3fe2c --- /dev/null +++ b/distributed_cli/src/contracts/mod.rs @@ -0,0 +1,59 @@ +//! Generic contract lifecycle records used by the CLI and its future checks. +//! +//! This module records ownership, references, identities, and diagnostics. It +//! deliberately does not model the semantic payload of an application, +//! deployment, migration, or client artifact. Those payloads remain owned by +//! their respective compiler or framework modules. + +mod artifact; +mod catalog; +mod chain; +mod closeout; +mod classification; +mod diagnostic; +mod migrations; +mod program; +mod snapshots; +mod transaction; + +#[cfg(test)] +mod tests; + +pub use artifact::{ + ArtifactIdentity, ArtifactPredecessor, ArtifactProvenance, ContractArtifactKind, + EnvironmentPolicyReference, +}; +pub use catalog::{ + ClientDeclaration, ClientInventory, ContractCatalog, ContractEntry, ContractError, + ContractScope, CLIENT_DECLARATION_SCHEMA_VERSION, CONTRACT_CATALOG_SCHEMA_VERSION, + MAX_CATALOG_BYTES, MAX_CATALOG_DIRECTORIES, MAX_CATALOG_DIRECTORY_DEPTH, + MAX_CATALOG_DIRECTORY_ENTRIES, MAX_CATALOG_ENTRIES, MAX_CATALOG_FILES, + MAX_CATALOG_GLOB_MATCHES, MAX_CATALOG_JSON_DEPTH, +}; +pub use chain::{check_predecessor_chain, ObservedPredecessor}; +pub use closeout::{classify_release_programs, close_local_contract_chain}; +pub use classification::{ + classify_snapshot_diff, decisions_are_distinct, ClassifiedChange, LifecycleDecision, +}; +pub use diagnostic::{ + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, SafeDiagnosticValue, +}; +pub use migrations::{ + check_migration_history, check_migration_inventory, BaselineAvailability, MigrationDialect, + MigrationEntry, MigrationFile, MigrationHistoryCheck, MigrationInventory, MAX_MIGRATIONS, + MAX_MIGRATION_INVENTORY_BYTES, MAX_MIGRATION_JSON_DEPTH, MAX_MIGRATION_SQL_BYTES, + MAX_MIGRATION_TOP_LEVEL_ENTRIES, MAX_MIGRATION_TOTAL_ENTRIES, MIGRATION_INVENTORY_PATH, + MIGRATION_INVENTORY_SCHEMA_VERSION, MIGRATION_OWNER, MIGRATION_SCOPE, +}; +pub use program::{ + ClientProgramArtifact, ClientProgramAsset, ClientProgramDescriptor, ClientProgramSurface, + ProgramCompatibility, CLIENT_PROGRAM_DESCRIPTOR_VERSION, CLIENT_PROGRAM_POLICY_VERSION, +}; +pub use snapshots::{ + diff_snapshots, snapshot_from_json, SemanticSnapshot, SnapshotChange, SnapshotDiff, + SnapshotEntry, MAX_SNAPSHOT_DEPTH, MAX_SNAPSHOT_PATHS, MAX_SNAPSHOT_VALUE_BYTES, +}; +pub use transaction::{ + contracts_accept, contracts_check, unknown_scope_diagnostic, ContractAcceptScope, + ContractsAcceptReport, ContractsCheckReport, +}; diff --git a/distributed_cli/src/contracts/program.rs b/distributed_cli/src/contracts/program.rs new file mode 100644 index 00000000..c2eb8c78 --- /dev/null +++ b/distributed_cli/src/contracts/program.rs @@ -0,0 +1,460 @@ +//! Client program descriptors and three-way compatibility classification. +//! +//! Hot-code-push handoff for deployable UI programs. No browser staging, +//! service workers, or activation behavior lives here. + +use super::artifact::{ArtifactIdentity, ContractArtifactKind}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +/// Descriptor wire version. +pub const CLIENT_PROGRAM_DESCRIPTOR_VERSION: u32 = 1; +/// Compatibility policy version for V1 exact mutation/offline identities. +pub const CLIENT_PROGRAM_POLICY_VERSION: u32 = 1; +pub const MAX_PROGRAM_ASSETS: usize = 8_192; +pub const MAX_PROGRAM_ASSET_BYTES: usize = 32 * 1024 * 1024; + +/// One portable asset in a deployable program tree. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramAsset { + /// Portable relative path under the deployable root. + pub path: String, + /// SHA-256 digest of file bytes (`sha256:`). + pub digest: String, + /// Optional SRI string when produced by the packaging pipeline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, + pub size_bytes: u64, +} + +/// One client surface bound into the program. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramSurface { + pub name: String, + /// Schema fingerprint from the accepted surface manifest. + pub schema_fingerprint: String, + /// Protocol fingerprint from the accepted surface manifest. + pub protocol_fingerprint: String, +} + +/// One generated operation/artifact identity referenced by the program. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramArtifact { + pub path: String, + pub digest: String, +} + +/// Complete deterministic client program descriptor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientProgramDescriptor { + pub version: u32, + pub policy_version: u32, + pub program_name: String, + /// Complete identity of the program (version + policy + contract set + assets). + pub program_id: String, + /// Sorted surface/artifact contract identities. + pub contract_set_id: String, + pub surfaces: Vec, + pub artifacts: Vec, + pub assets: Vec, + /// Immediate application-manifest predecessor identity. + pub application_manifest: ArtifactIdentity, + /// Immediate deployment-plan predecessor identity. + pub deployment_plan: ArtifactIdentity, +} + +/// Three-way compatibility classification for loaded vs advertised programs. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProgramCompatibility { + /// Exact program identity match. + Current, + /// Contract set identical; only assets differ (hot asset push). + CompatibleAssetOnlyUpdate, + /// Mutation/offline contract set differs or material is incomplete. + IncompatibleRequiredUpdate, +} + +impl ClientProgramDescriptor { + pub fn builder(program_name: impl Into) -> ClientProgramDescriptorBuilder { + ClientProgramDescriptorBuilder { + program_name: program_name.into(), + surfaces: Vec::new(), + artifacts: Vec::new(), + assets: Vec::new(), + application_manifest: None, + deployment_plan: None, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.version != CLIENT_PROGRAM_DESCRIPTOR_VERSION { + return Err(format!( + "unsupported client program descriptor version {}", + self.version + )); + } + if self.policy_version != CLIENT_PROGRAM_POLICY_VERSION { + return Err(format!( + "unsupported client program policy version {}", + self.policy_version + )); + } + if self.program_name.trim().is_empty() { + return Err("program_name must not be empty".into()); + } + if self.surfaces.is_empty() { + return Err("program must declare at least one surface".into()); + } + if self.assets.len() > MAX_PROGRAM_ASSETS { + return Err(format!("program exceeds max asset count {MAX_PROGRAM_ASSETS}")); + } + let mut paths = BTreeSet::new(); + for asset in &self.assets { + validate_portable_path(&asset.path)?; + if !paths.insert(asset.path.clone()) { + return Err(format!("duplicate portable asset path `{}`", asset.path)); + } + if !asset.digest.starts_with("sha256:") { + return Err(format!("asset `{}` digest must be sha256", asset.path)); + } + } + for surface in &self.surfaces { + if surface.name.trim().is_empty() { + return Err("surface name must not be empty".into()); + } + } + if self.application_manifest.kind != ContractArtifactKind::ApplicationManifest { + return Err("application_manifest predecessor kind mismatch".into()); + } + if self.deployment_plan.kind != ContractArtifactKind::DeploymentPlan { + return Err("deployment_plan predecessor kind mismatch".into()); + } + let expected_contract = contract_set_id(&self.surfaces, &self.artifacts); + if self.contract_set_id != expected_contract { + return Err("contract_set_id is stale relative to surfaces/artifacts".into()); + } + let expected_program = program_id( + self.version, + self.policy_version, + &self.contract_set_id, + &self.assets, + ); + if self.program_id != expected_program { + return Err("program_id is stale relative to contract set and assets".into()); + } + Ok(()) + } + + pub fn canonical_bytes(&self) -> Result, String> { + self.validate()?; + serde_json::to_vec(self).map_err(|error| error.to_string()) + } + + pub fn classify_against(&self, loaded: &Self) -> Result { + self.validate()?; + loaded.validate()?; + if self.program_id == loaded.program_id { + return Ok(ProgramCompatibility::Current); + } + if self.contract_set_id == loaded.contract_set_id { + return Ok(ProgramCompatibility::CompatibleAssetOnlyUpdate); + } + Ok(ProgramCompatibility::IncompatibleRequiredUpdate) + } +} + +pub struct ClientProgramDescriptorBuilder { + program_name: String, + surfaces: Vec, + artifacts: Vec, + assets: Vec, + application_manifest: Option, + deployment_plan: Option, +} + +impl ClientProgramDescriptorBuilder { + pub fn surface(mut self, surface: ClientProgramSurface) -> Self { + self.surfaces.push(surface); + self + } + + pub fn artifact(mut self, artifact: ClientProgramArtifact) -> Self { + self.artifacts.push(artifact); + self + } + + pub fn asset(mut self, asset: ClientProgramAsset) -> Self { + self.assets.push(asset); + self + } + + pub fn application_manifest(mut self, identity: ArtifactIdentity) -> Self { + self.application_manifest = Some(identity); + self + } + + pub fn deployment_plan(mut self, identity: ArtifactIdentity) -> Self { + self.deployment_plan = Some(identity); + self + } + + /// Hash every regular file under `root` into portable assets. + pub fn assets_from_dir(mut self, root: &Path) -> Result { + let mut assets = collect_assets(root)?; + assets.sort(); + self.assets.extend(assets); + Ok(self) + } + + pub fn build(mut self) -> Result { + self.surfaces.sort_by(|a, b| a.name.cmp(&b.name)); + self.artifacts.sort_by(|a, b| a.path.cmp(&b.path)); + self.assets.sort(); + let application_manifest = self.application_manifest.ok_or_else(|| { + "client program requires application_manifest predecessor identity".to_string() + })?; + let deployment_plan = self.deployment_plan.ok_or_else(|| { + "client program requires deployment_plan predecessor identity".to_string() + })?; + let contract_set_id = contract_set_id(&self.surfaces, &self.artifacts); + let program_id = program_id( + CLIENT_PROGRAM_DESCRIPTOR_VERSION, + CLIENT_PROGRAM_POLICY_VERSION, + &contract_set_id, + &self.assets, + ); + let descriptor = ClientProgramDescriptor { + version: CLIENT_PROGRAM_DESCRIPTOR_VERSION, + policy_version: CLIENT_PROGRAM_POLICY_VERSION, + program_name: self.program_name, + program_id, + contract_set_id, + surfaces: self.surfaces, + artifacts: self.artifacts, + assets: self.assets, + application_manifest, + deployment_plan, + }; + descriptor.validate()?; + Ok(descriptor) + } +} + +fn contract_set_id( + surfaces: &[ClientProgramSurface], + artifacts: &[ClientProgramArtifact], +) -> String { + let mut material = BTreeMap::new(); + for surface in surfaces { + material.insert( + format!("surface:{}", surface.name), + format!( + "{}|{}", + surface.schema_fingerprint, surface.protocol_fingerprint + ), + ); + } + for artifact in artifacts { + material.insert(format!("artifact:{}", artifact.path), artifact.digest.clone()); + } + let bytes = serde_json::to_vec(&material).unwrap_or_default(); + identity_digest("distributed.client-program.contract-set.v1", &bytes) +} + +fn program_id( + version: u32, + policy_version: u32, + contract_set_id: &str, + assets: &[ClientProgramAsset], +) -> String { + let asset_ids = assets + .iter() + .map(|asset| format!("{}:{}", asset.path, asset.digest)) + .collect::>(); + let material = serde_json::json!({ + "version": version, + "policy_version": policy_version, + "contract_set_id": contract_set_id, + "assets": asset_ids, + }); + let bytes = serde_json::to_vec(&material).unwrap_or_default(); + identity_digest("distributed.client-program.program-id.v1", &bytes) +} + +fn collect_assets(root: &Path) -> Result, String> { + if !root.is_dir() { + return Err(format!("asset root `{}` is not a directory", root.display())); + } + let root = root.canonicalize().map_err(|e| e.to_string())?; + let mut assets = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let meta = entry.metadata().map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() { + return Err(format!("symlink assets are rejected: {}", path.display())); + } + if meta.is_dir() { + stack.push(path); + continue; + } + if !meta.is_file() { + return Err(format!("special files are rejected: {}", path.display())); + } + if meta.len() as usize > MAX_PROGRAM_ASSET_BYTES { + return Err(format!( + "asset `{}` exceeds max bytes {MAX_PROGRAM_ASSET_BYTES}", + path.display() + )); + } + let relative = path + .strip_prefix(&root) + .map_err(|_| "asset escaped root".to_string())? + .to_string_lossy() + .replace('\\', "/"); + validate_portable_path(&relative)?; + let bytes = fs::read(&path).map_err(|e| e.to_string())?; + let digest = { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + format!("sha256:{:x}", hasher.finalize()) + }; + assets.push(ClientProgramAsset { + path: relative, + digest, + integrity: None, + size_bytes: meta.len(), + }); + if assets.len() > MAX_PROGRAM_ASSETS { + return Err(format!("asset tree exceeds max count {MAX_PROGRAM_ASSETS}")); + } + } + } + Ok(assets) +} + +fn validate_portable_path(path: &str) -> Result<(), String> { + if path.is_empty() || path.starts_with('/') || path.contains('\0') { + return Err(format!("invalid portable path `{path}`")); + } + if Path::new(path) + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(format!("path traversal rejected: `{path}`")); + } + Ok(()) +} + +// Local helper so program module does not depend on application identity. +fn identity_digest(domain: &str, bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain.as_bytes()); + digest.update(b"\0"); + digest.update(bytes); + format!("sha256:{:x}", digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("client-program-{nanos}")); + fs::create_dir_all(&path).unwrap(); + path + } + + fn base_builder(assets_dir: &Path) -> ClientProgramDescriptorBuilder { + ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:schema".into(), + protocol_fingerprint: "sha256:protocol".into(), + }) + .artifact(ClientProgramArtifact { + path: "operations/todos.ts".into(), + digest: "sha256:op".into(), + }) + .application_manifest(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + )) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:plan", + )) + .assets_from_dir(assets_dir) + .unwrap() + } + + #[test] + fn descriptor_is_byte_deterministic_and_classifies_asset_only_updates() { + let root = temp_dir(); + fs::write(root.join("app.js"), b"console.log(1)").unwrap(); + let first = base_builder(&root).build().unwrap(); + let second = base_builder(&root).build().unwrap(); + assert_eq!(first.canonical_bytes().unwrap(), second.canonical_bytes().unwrap()); + assert_eq!( + first.classify_against(&second).unwrap(), + ProgramCompatibility::Current + ); + + fs::write(root.join("app.js"), b"console.log(2)").unwrap(); + let asset_changed = base_builder(&root).build().unwrap(); + assert_eq!( + first.classify_against(&asset_changed).unwrap(), + ProgramCompatibility::CompatibleAssetOnlyUpdate + ); + assert_eq!(first.contract_set_id, asset_changed.contract_set_id); + assert_ne!(first.program_id, asset_changed.program_id); + + let mut surface_changed = base_builder(&root).build().unwrap(); + surface_changed.surfaces[0].schema_fingerprint = "sha256:other".into(); + // Rebuild ids after mutation via builder path + let surface_changed = ClientProgramDescriptor::builder("e2e-ui") + .surface(ClientProgramSurface { + name: "e2e-ui".into(), + schema_fingerprint: "sha256:other".into(), + protocol_fingerprint: "sha256:protocol".into(), + }) + .artifact(ClientProgramArtifact { + path: "operations/todos.ts".into(), + digest: "sha256:op".into(), + }) + .application_manifest(ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "sha256:app", + )) + .deployment_plan(ArtifactIdentity::new( + ContractArtifactKind::DeploymentPlan, + "sha256:plan", + )) + .assets_from_dir(&root) + .unwrap() + .build() + .unwrap(); + assert_eq!( + first.classify_against(&surface_changed).unwrap(), + ProgramCompatibility::IncompatibleRequiredUpdate + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/distributed_cli/src/contracts/snapshots.rs b/distributed_cli/src/contracts/snapshots.rs new file mode 100644 index 00000000..6c387e62 --- /dev/null +++ b/distributed_cli/src/contracts/snapshots.rs @@ -0,0 +1,253 @@ +//! Canonical semantic snapshots for lifecycle review. +//! +//! Snapshots capture behavior-affecting material as reviewable paths and values +//! rather than opaque hashes alone. Absolute paths, timestamps, environment +//! values, and secrets are excluded. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Maximum number of snapshot paths retained for one artifact. +pub const MAX_SNAPSHOT_PATHS: usize = 16_384; +/// Maximum depth when walking nested JSON. +pub const MAX_SNAPSHOT_DEPTH: usize = 32; +/// Maximum string value bytes retained in a snapshot entry. +pub const MAX_SNAPSHOT_VALUE_BYTES: usize = 4_096; + +/// One canonical semantic path and its reviewable value. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotEntry { + /// Dot-path into the semantic material (stable, sorted). + pub path: String, + /// JSON value at the path (objects already flattened into child paths). + pub value: Value, +} + +/// Deterministic semantic snapshot for one artifact owner. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticSnapshot { + /// Semantic owner identity (spec/source path or catalog entry id). + pub owner: String, + /// Artifact kind label (surface, application_manifest, deployment_plan, …). + pub kind: String, + /// Sorted path inventory. + pub entries: Vec, + /// Content digest of the canonical snapshot bytes. + pub digest: String, +} + +/// Diff between two snapshots. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotDiff { + pub owner: String, + pub kind: String, + pub changes: Vec, +} + +/// One path-level change requiring a lifecycle decision. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotChange { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// Build a semantic snapshot from arbitrary JSON by flattening into sorted paths. +pub fn snapshot_from_json( + owner: impl Into, + kind: impl Into, + value: &Value, +) -> Result { + let owner = owner.into(); + let kind = kind.into(); + let mut paths = BTreeMap::new(); + flatten_value("", value, 0, &mut paths)?; + if paths.len() > MAX_SNAPSHOT_PATHS { + return Err(format!( + "snapshot for `{owner}` exceeds max path count {MAX_SNAPSHOT_PATHS}" + )); + } + let entries = paths + .into_iter() + .map(|(path, value)| SnapshotEntry { path, value }) + .collect::>(); + let digest = digest_entries(&entries); + Ok(SemanticSnapshot { + owner, + kind, + entries, + digest, + }) +} + +/// Diff two snapshots of the same owner/kind. +pub fn diff_snapshots(before: &SemanticSnapshot, after: &SemanticSnapshot) -> SnapshotDiff { + let mut changes = Vec::new(); + let mut before_map = before + .entries + .iter() + .map(|entry| (entry.path.as_str(), &entry.value)) + .collect::>(); + for entry in &after.entries { + match before_map.remove(entry.path.as_str()) { + Some(previous) if previous == &entry.value => {} + Some(previous) => changes.push(SnapshotChange { + path: entry.path.clone(), + before: Some(previous.clone()), + after: Some(entry.value.clone()), + }), + None => changes.push(SnapshotChange { + path: entry.path.clone(), + before: None, + after: Some(entry.value.clone()), + }), + } + } + for (path, previous) in before_map { + changes.push(SnapshotChange { + path: path.to_string(), + before: Some(previous.clone()), + after: None, + }); + } + changes.sort_by(|left, right| left.path.cmp(&right.path)); + SnapshotDiff { + owner: after.owner.clone(), + kind: after.kind.clone(), + changes, + } +} + +fn flatten_value( + prefix: &str, + value: &Value, + depth: usize, + out: &mut BTreeMap, +) -> Result<(), String> { + if depth > MAX_SNAPSHOT_DEPTH { + return Err(format!( + "snapshot depth exceeds maximum {MAX_SNAPSHOT_DEPTH} at `{prefix}`" + )); + } + match value { + Value::Object(fields) => { + // Skip volatile / non-semantic keys. + for (key, child) in fields { + if is_volatile_key(key) { + continue; + } + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten_value(&path, child, depth + 1, out)?; + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + let path = format!("{prefix}[{index}]"); + flatten_value(&path, child, depth + 1, out)?; + } + } + Value::String(text) if text.len() > MAX_SNAPSHOT_VALUE_BYTES => { + out.insert( + prefix.to_string(), + Value::String(format!( + "", + text.len() + )), + ); + } + other => { + out.insert(prefix.to_string(), other.clone()); + } + } + Ok(()) +} + +fn is_volatile_key(key: &str) -> bool { + matches!( + key, + "generated_at" + | "timestamp" + | "wall_time" + | "absolute_path" + | "cwd" + | "hostname" + | "env" + | "environment" + | "secret" + | "password" + | "token" + | "connection_string" + ) +} + +fn digest_entries(entries: &[SnapshotEntry]) -> String { + let bytes = serde_json::to_vec(entries).unwrap_or_default(); + let mut digest = Sha256::new(); + digest.update(b"distributed.contract.snapshot.v1\0"); + digest.update(&bytes); + format!("sha256:{:x}", digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_snapshot_ignores_unordered_input_but_detects_nullability() { + let left = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "title": { "nullable": false }, "id": { "nullable": false } } } + }, + "generated_at": "2026-01-01T00:00:00Z" + }), + ) + .unwrap(); + let right = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": false } } } + } + }), + ) + .unwrap(); + assert_eq!(left.digest, right.digest); + assert!(diff_snapshots(&left, &right).changes.is_empty()); + + let drifted = snapshot_from_json( + "surface/web", + "surface_client_manifest", + &json!({ + "models": { + "TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": true } } } + } + }), + ) + .unwrap(); + let changes = diff_snapshots(&left, &drifted).changes; + assert_eq!(changes.len(), 1); + assert_eq!( + changes[0].path, + "models.TodoView.fields.title.nullable" + ); + assert_eq!(changes[0].before, Some(json!(false))); + assert_eq!(changes[0].after, Some(json!(true))); + } +} diff --git a/distributed_cli/src/contracts/tests.rs b/distributed_cli/src/contracts/tests.rs new file mode 100644 index 00000000..127f3837 --- /dev/null +++ b/distributed_cli/src/contracts/tests.rs @@ -0,0 +1,1618 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +#[cfg(unix)] +use std::os::unix::fs::symlink; +#[cfg(unix)] +use std::os::unix::net::UnixListener; + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("distributed_cli has a repository parent") + .to_path_buf() +} + +fn fixture(name: &str) -> &'static str { + match name { + "valid" => include_str!("../../tests/fixtures/contracts/catalog/catalog-valid.json"), + "duplicate" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-duplicate-scope.json") + } + "escaping" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-escaping-path.json") + } + "cycle" => include_str!("../../tests/fixtures/contracts/catalog/catalog-chain-cycle.json"), + "environment" => { + include_str!("../../tests/fixtures/contracts/catalog/catalog-environment-value.json") + } + _ => panic!("unknown catalog fixture {name}"), + } +} + +fn path_catalog(source: &str) -> ContractCatalog { + let input = serde_json::json!({ + "schema_version": 1, + "entries": { + "path-test": { + "id": "path-test", + "kind": "migration_inventory", + "scope": { "id": "path/test" }, + "owner": "test/path", + "identity": { + "kind": "migration_inventory", + "value": "sha256:path-test" + }, + "provenance": { + "sources": [source], + "generator": "test.path" + }, + "outputs": { "output": "inside.txt" } + } + } + }); + ContractCatalog::from_json_str(&input.to_string()).expect("path catalog JSON") +} + +fn migration_checksum(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn write_repository_file(root: &Path, relative: &str, bytes: &[u8]) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("migration file parent")) + .expect("migration file parent directory"); + fs::write(path, bytes).expect("migration fixture file"); +} + +fn migration_entry( + version: u64, + sqlite_path: &str, + sqlite_sql: &[u8], + postgres_path: &str, + postgres_sql: &[u8], +) -> Value { + json!({ + "version": version, + "description": if version == 1 { "initial" } else { "next" }, + "sqlite": { + "path": sqlite_path, + "sha256": migration_checksum(sqlite_sql) + }, + "postgres": { + "path": postgres_path, + "sha256": migration_checksum(postgres_sql) + } + }) +} + +fn write_migration_inventory(root: &Path, entries: &[Value]) { + let path = root.join(MIGRATION_INVENTORY_PATH); + fs::create_dir_all(path.parent().expect("inventory parent directory")) + .expect("inventory parent directory"); + fs::write( + path, + serde_json::to_vec_pretty(&json!({ + "schema_version": MIGRATION_INVENTORY_SCHEMA_VERSION, + "migrations": entries + })) + .expect("migration inventory JSON"), + ) + .expect("migration inventory file"); +} + +fn create_migration_fixture(label: &str) -> (TemporaryDirectory, Vec, Vec) { + let root = TemporaryDirectory::new_short(label); + let sqlite_sql = b"CREATE TABLE one (id INTEGER PRIMARY KEY);\n".to_vec(); + let postgres_sql = b"CREATE TABLE one (id BIGINT PRIMARY KEY);\n".to_vec(); + let sqlite_path = "migrations/sqlite/0001_initial.sql"; + let postgres_path = "migrations/postgres/0001_initial.sql"; + write_repository_file(root.path(), sqlite_path, &sqlite_sql); + write_repository_file(root.path(), postgres_path, &postgres_sql); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + sqlite_path, + &sqlite_sql, + postgres_path, + &postgres_sql, + )], + ); + (root, sqlite_sql, postgres_sql) +} + +fn git_fixture_command(root: &Path, args: &[&str]) -> std::process::Output { + Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .expect("invoke git fixture command") +} + +fn commit_migration_fixture(root: &Path) -> String { + for args in [ + ["init", "-q"].as_slice(), + ["config", "user.email", "migration-tests@example.invalid"].as_slice(), + ["config", "user.name", "Migration Tests"].as_slice(), + ["add", "--all"].as_slice(), + ["commit", "-qm", "baseline"].as_slice(), + ] { + let output = git_fixture_command(root, args); + assert!( + output.status.success(), + "git fixture command {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + let output = git_fixture_command(root, &["rev-parse", "HEAD"]); + assert!(output.status.success(), "read fixture revision"); + String::from_utf8(output.stdout) + .expect("fixture revision UTF-8") + .trim() + .to_string() +} + +fn glob_catalog(source: &str, glob_limit: usize) -> ContractCatalog { + let input = serde_json::json!({ + "schema_version": 1, + "entries": { + "glob-test": { + "id": "glob-test", + "kind": "migration_inventory", + "scope": { "id": "path/glob" }, + "owner": "test/glob", + "identity": { + "kind": "migration_inventory", + "value": "sha256:glob-test" + }, + "provenance": { + "sources": [source], + "generator": "test.glob", + "glob_limit": glob_limit + }, + "outputs": { "output": "inside.txt" } + } + } + }); + ContractCatalog::from_json_str(&input.to_string()).expect("glob catalog JSON") +} + +fn create_sparse_file(path: &Path, length: usize) { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .expect("sparse file"); + file.set_len(length as u64).expect("sparse file length"); +} + +struct TemporaryDirectory(PathBuf); + +impl TemporaryDirectory { + fn new(label: &str) -> Self { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let base = std::env::temp_dir(); + for attempt in 0..100 { + let path = base.join(format!( + "distributed-contracts-{label}-{}-{timestamp}-{attempt}", + std::process::id() + )); + if fs::create_dir(&path).is_ok() { + return Self(path); + } + } + panic!("could not create temporary directory for {label}"); + } + + fn path(&self) -> &Path { + &self.0 + } + + #[cfg(unix)] + fn new_short(label: &str) -> Self { + let base = std::env::temp_dir(); + for attempt in 0..100 { + let path = base.join(format!("dct-{label}-{}-{attempt}", std::process::id())); + if fs::create_dir(&path).is_ok() { + return Self(path); + } + } + panic!("could not create short temporary directory for {label}"); + } +} + +impl Drop for TemporaryDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn catalog_references_one_declarative_client_inventory() { + let root = repository_root(); + let catalog = ContractCatalog::from_path(root.join("distributed.contracts.json")) + .expect("repository catalog should resolve without writing"); + let inventory = + ClientInventory::from_path(root.join("tests/e2e-ui/ui/distributed.clients.json")) + .expect("client inventory should use the shared schema"); + + assert_eq!(inventory.clients.len(), 3); + assert!(inventory + .clients + .iter() + .any(|client| client.surface == "e2e-ui-admin")); + assert_eq!( + catalog.canonical_bytes().expect("canonical catalog"), + ContractCatalog::from_json_str( + std::str::from_utf8(&catalog.canonical_bytes().expect("canonical catalog")) + .expect("canonical catalog is UTF-8") + ) + .expect("canonical catalog parses") + .canonical_bytes() + .expect("second canonical catalog") + ); +} + +#[test] +fn catalog_from_path_accepts_repository_relative_catalog_name() { + if std::env::var_os("DISTRIBUTED_CATALOG_RELATIVE_PATH_CHILD").is_some() { + ContractCatalog::from_path("distributed.contracts.json") + .expect("repository-relative catalog path should resolve from the repository root"); + return; + } + + let status = Command::new(std::env::current_exe().expect("contract test executable")) + .current_dir(repository_root()) + .env("DISTRIBUTED_CATALOG_RELATIVE_PATH_CHILD", "1") + .args([ + "--exact", + "contracts::tests::catalog_from_path_accepts_repository_relative_catalog_name", + "--quiet", + ]) + .status() + .expect("run repository-relative catalog test from the repository root"); + assert!(status.success()); +} + +#[test] +fn catalog_and_inventory_reject_sparse_oversized_files_before_reading() { + let root = TemporaryDirectory::new("sparse-input"); + let catalog_path = root.path().join("distributed.contracts.json"); + create_sparse_file(&catalog_path, MAX_CATALOG_BYTES + 1); + let catalog_error = ContractCatalog::from_path(&catalog_path) + .expect_err("sparse oversized catalog must fail before allocation"); + assert_eq!( + catalog_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); + + let inventory_path = root.path().join("distributed.clients.json"); + create_sparse_file(&inventory_path, MAX_CATALOG_BYTES + 1); + let inventory_error = ClientInventory::from_path(&inventory_path) + .expect_err("sparse oversized inventory must fail before allocation"); + assert_eq!( + inventory_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[test] +fn catalog_rejects_duplicate_scope_and_escaping_paths() { + let duplicate = ContractCatalog::from_json_str(fixture("duplicate")) + .expect_err("duplicate scopes must fail"); + assert_eq!( + duplicate.code(), + ContractDiagnosticCode::CatalogDuplicateScope + ); + + let escaping = ContractCatalog::from_json_str(fixture("escaping")) + .expect_err("parent traversal must fail"); + assert_eq!(escaping.code(), ContractDiagnosticCode::CatalogPath); +} + +#[test] +fn catalog_rejects_unknown_kinds_duplicate_owners_outputs_and_unbounded_globs() { + let mut unknown_kind: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + unknown_kind["entries"]["application-manifest"]["kind"] = + Value::String("future_artifact".to_string()); + let unknown_kind = ContractCatalog::from_json_str( + &serde_json::to_string(&unknown_kind).expect("unknown kind JSON"), + ) + .expect_err("unknown kinds must fail"); + assert_eq!( + unknown_kind.code(), + ContractDiagnosticCode::CatalogUnknownKind + ); + + let mut duplicate_owner: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + duplicate_owner["entries"]["deployment-plan"]["owner"] = + duplicate_owner["entries"]["application-manifest"]["owner"].clone(); + let duplicate_owner = ContractCatalog::from_json_str( + &serde_json::to_string(&duplicate_owner).expect("duplicate owner JSON"), + ) + .expect_err("duplicate owners must fail"); + assert_eq!( + duplicate_owner.code(), + ContractDiagnosticCode::CatalogDuplicateOwner + ); + + let mut duplicate_output: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + duplicate_output["entries"]["deployment-plan"]["outputs"]["deployment"] = + duplicate_output["entries"]["application-manifest"]["outputs"]["application"].clone(); + let duplicate_output = ContractCatalog::from_json_str( + &serde_json::to_string(&duplicate_output).expect("duplicate output JSON"), + ) + .expect_err("duplicate outputs must fail"); + assert_eq!( + duplicate_output.code(), + ContractDiagnosticCode::CatalogDuplicateOutput + ); + + let mut unbounded_glob: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + unbounded_glob["entries"]["application-manifest"]["provenance"]["sources"][0] = + Value::String("migrations/**/*.sql".to_string()); + unbounded_glob["entries"]["application-manifest"]["provenance"] + .as_object_mut() + .expect("provenance object") + .remove("glob_limit"); + let unbounded_glob = ContractCatalog::from_json_str( + &serde_json::to_string(&unbounded_glob).expect("unbounded glob JSON"), + ) + .expect_err("unbounded globs must fail"); + assert_eq!( + unbounded_glob.code(), + ContractDiagnosticCode::CatalogUnboundedGlob + ); +} + +#[test] +fn catalog_rejects_recursive_globs_even_with_a_match_limit() { + let mut recursive_glob: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + recursive_glob["entries"]["application-manifest"]["provenance"]["sources"][0] = + Value::String("migrations/**/*.sql".to_string()); + recursive_glob["entries"]["application-manifest"]["provenance"]["glob_limit"] = Value::from(1); + let recursive_glob = ContractCatalog::from_json_str( + &serde_json::to_string(&recursive_glob).expect("recursive glob JSON"), + ) + .expect_err("recursive globs must fail despite a match limit"); + assert_eq!( + recursive_glob.code(), + ContractDiagnosticCode::CatalogUnboundedGlob + ); +} + +#[test] +fn catalog_glob_discovery_bounds_candidate_directory_entries() { + let root = TemporaryDirectory::new("glob-candidates"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let candidates = root.path().join("candidates"); + fs::create_dir(&candidates).expect("candidate directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write( + candidates.join(format!("candidate-{index:04}.txt")), + b"candidate", + ) + .expect("candidate fixture"); + } + + let error = glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect_err("glob candidate traversal must be bounded independently of matches"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogInputLimit); +} + +#[cfg(unix)] +#[test] +fn catalog_glob_uses_canonical_symlinked_parent_for_candidate_limits() { + let root = TemporaryDirectory::new_short("glob-symlink-parent"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let target = root.path().join("candidate-target"); + fs::create_dir(&target).expect("candidate target directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write( + target.join(format!("candidate-{index:04}.txt")), + b"candidate", + ) + .expect("candidate fixture"); + } + symlink(&target, root.path().join("candidates")).expect("symlinked candidate directory"); + + let error = glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect_err("symlinked glob candidate traversal must be bounded"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogInputLimit); +} + +#[test] +fn catalog_accepts_a_glob_within_a_bounded_candidate_directory() { + let root = TemporaryDirectory::new("bounded-glob"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let candidates = root.path().join("candidates"); + fs::create_dir(&candidates).expect("candidate directory"); + fs::write(candidates.join("selected.sql"), b"candidate").expect("candidate fixture"); + + glob_catalog("candidates/*.sql", 1) + .validate_paths(root.path()) + .expect("bounded glob candidate traversal"); +} + +#[test] +fn catalog_rejects_absolute_paths_and_exhausting_filesystem_traversal() { + let absolute = ContractCatalog::from_json_str( + &serde_json::json!({ + "schema_version": 1, + "entries": { + "absolute": { + "id": "absolute", + "kind": "migration_inventory", + "scope": { "id": "path/absolute" }, + "owner": "test/absolute", + "identity": { + "kind": "migration_inventory", + "value": "sha256:absolute" + }, + "provenance": { + "sources": ["/etc/passwd"], + "generator": "test.absolute" + }, + "outputs": { "output": "inside.txt" } + } + } + }) + .to_string(), + ) + .expect_err("absolute paths must fail"); + assert_eq!(absolute.code(), ContractDiagnosticCode::CatalogPath); + + let root = TemporaryDirectory::new("traversal"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let empty_tree = root.path().join("empty-tree"); + fs::create_dir(&empty_tree).expect("empty tree"); + for index in 0..=MAX_CATALOG_DIRECTORIES { + fs::create_dir(empty_tree.join(format!("directory-{index:04}"))) + .expect("empty child directory"); + } + let traversal = path_catalog("empty-tree") + .validate_paths(root.path()) + .expect_err("empty directories must be bounded"); + assert_eq!(traversal.code(), ContractDiagnosticCode::CatalogInputLimit); + + let deep_tree = root.path().join("deep-tree"); + fs::create_dir(&deep_tree).expect("deep tree"); + let mut current = deep_tree; + for depth in 0..=MAX_CATALOG_DIRECTORY_DEPTH { + current = current.join(format!("level-{depth:02}")); + fs::create_dir(¤t).expect("deep child directory"); + } + let depth_error = path_catalog("deep-tree") + .validate_paths(root.path()) + .expect_err("directory depth must be bounded"); + assert_eq!( + depth_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); + + let many_entries = root.path().join("many-entries"); + fs::create_dir(&many_entries).expect("many-entry directory"); + for index in 0..=MAX_CATALOG_DIRECTORY_ENTRIES { + fs::write(many_entries.join(format!("file-{index:04}")), b"entry") + .expect("many-entry file"); + } + let entry_error = path_catalog("many-entries") + .validate_paths(root.path()) + .expect_err("directory entries must be bounded"); + assert_eq!( + entry_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[test] +fn catalog_rejects_excessive_json_bytes_and_depth() { + let oversized = format!( + "{{\"schema_version\":1,\"entries\":{{}},\"padding\":\"{}\"}}", + "x".repeat(MAX_CATALOG_BYTES) + ); + let byte_error = ContractCatalog::from_json_str(&oversized) + .expect_err("oversized JSON must fail before parsing"); + assert_eq!(byte_error.code(), ContractDiagnosticCode::CatalogInputLimit); + + let mut deeply_nested = String::from("{\"padding\":"); + for _ in 0..=MAX_CATALOG_JSON_DEPTH { + deeply_nested.push('['); + } + deeply_nested.push_str("null"); + for _ in 0..=MAX_CATALOG_JSON_DEPTH { + deeply_nested.push(']'); + } + deeply_nested.push('}'); + let depth_error = + ContractCatalog::from_json_str(&deeply_nested).expect_err("deeply nested JSON must fail"); + assert_eq!( + depth_error.code(), + ContractDiagnosticCode::CatalogInputLimit + ); +} + +#[cfg(unix)] +#[test] +fn catalog_rejects_symlink_and_special_file_paths() { + let root = TemporaryDirectory::new_short("filesystem"); + let outside = TemporaryDirectory::new("outside"); + fs::write(root.path().join("inside.txt"), b"output").expect("output fixture"); + let outside_file = outside.path().join("outside.txt"); + fs::write(&outside_file, b"outside").expect("outside fixture"); + + symlink(&outside_file, root.path().join("escaped")).expect("symlink fixture"); + let symlink_error = path_catalog("escaped") + .validate_paths(root.path()) + .expect_err("symlink escapes must fail"); + assert_eq!( + symlink_error.code(), + ContractDiagnosticCode::CatalogSymlinkEscape + ); + + let socket_path = root.path().join("special.sock"); + let _listener = UnixListener::bind(&socket_path).expect("socket fixture"); + let special_error = path_catalog("special.sock") + .validate_paths(root.path()) + .expect_err("special files must fail"); + assert_eq!( + special_error.code(), + ContractDiagnosticCode::CatalogSpecialFile + ); +} + +#[cfg(unix)] +#[test] +fn client_inventory_loader_rejects_symlink_inputs() { + let root = TemporaryDirectory::new_short("inventory-symlink"); + let inventory = root.path().join("distributed.clients.json"); + fs::write( + &inventory, + include_str!("../../../tests/e2e-ui/ui/distributed.clients.json"), + ) + .expect("inventory fixture"); + let link = root.path().join("inventory-link.json"); + symlink(&inventory, &link).expect("inventory symlink fixture"); + + let error = ClientInventory::from_path(&link) + .expect_err("inventory symlink inputs must be rejected before following them"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSymlinkEscape); + assert_eq!(error.message(), "client inventory must not be a symlink"); +} + +#[test] +fn artifact_chain_rejects_cycles_kind_mismatch_and_environment_values() { + let cycle = + ContractCatalog::from_json_str(fixture("cycle")).expect_err("predecessor cycles must fail"); + assert_eq!(cycle.code(), ContractDiagnosticCode::ChainCycle); + + let mut missing_predecessor: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + missing_predecessor["entries"]["deployment-plan"]["predecessor"]["entry_id"] = + Value::String("missing-predecessor".to_string()); + let missing_predecessor = ContractCatalog::from_json_str( + &serde_json::to_string(&missing_predecessor).expect("missing predecessor JSON"), + ) + .expect_err("missing predecessor must fail"); + assert_eq!( + missing_predecessor.code(), + ContractDiagnosticCode::ChainMissingPredecessor + ); + + let mut kind_mismatch: Value = + serde_json::from_str(fixture("valid")).expect("valid fixture JSON"); + kind_mismatch["entries"]["deployment-plan"]["predecessor"]["identity"]["kind"] = + Value::String("deployment_plan".to_string()); + let kind_mismatch = ContractCatalog::from_json_str( + &serde_json::to_string(&kind_mismatch).expect("kind mismatch JSON"), + ) + .expect_err("predecessor kind mismatch must fail"); + assert_eq!( + kind_mismatch.code(), + ContractDiagnosticCode::ChainKindMismatch + ); + + let environment = ContractCatalog::from_json_str(fixture("environment")) + .expect_err("raw environment values must fail"); + assert_eq!(environment.code(), ContractDiagnosticCode::EnvironmentValue); +} + +#[test] +fn human_and_json_diagnostics_preserve_same_facts_and_redact_values() { + let migration = ContractDiagnostic::new( + ContractDiagnosticCode::MigrationInventory, + Some(ContractArtifactKind::MigrationInventory), + Some("repository/migrations"), + "distributed::migrations", + ["migrations/postgres/0005_new.sql"], + ["distributed.contracts.json"], + None::<&str>, + Some("sha256:before"), + Some("sha256:after"), + Some("add migration 5"), + Some(true), + "distributed contracts check --base ", + ) + .with_detail("migration inventory is missing a registered file"); + let schema = ContractDiagnostic::new( + ContractDiagnosticCode::SchemaDrift, + Some(ContractArtifactKind::SurfaceClientManifest), + Some("e2e-ui"), + "query-layer::surface", + ["tests/e2e-ui/ui/distributed.clients.json"], + ["tests/e2e-ui/ui/src/lib/generated/user/manifest.json"], + Some("models.Todo.owner.nullable"), + Some("postgres://user:password@example.invalid/db"), + Some("Bearer hidden-token"), + Some("surface schema drift"), + None, + "distributed contracts accept --scope client:e2e-ui", + ); + let mut result = ContractCheckResult::default(); + result.push(migration.clone()); + result.push(schema.clone()); + + let human = result.human(); + let json: Value = serde_json::from_str(&result.to_json().expect("diagnostic JSON")) + .expect("diagnostic JSON object"); + assert!(human.contains("CTL-MIG-INVENTORY")); + assert!(human.contains("CTL-SCHEMA-DRIFT")); + assert!(human.contains("[REDACTED]")); + assert!(!human.contains("postgres://")); + assert_eq!( + json["diagnostics"] + .as_array() + .expect("diagnostic list") + .len(), + 2 + ); + assert_eq!(json["diagnostics"][1]["code"], "CTL-SCHEMA-DRIFT"); + assert_eq!(json["diagnostics"][1]["expected"], "[REDACTED]"); + assert_eq!(json["diagnostics"][1]["observed"], "[REDACTED]"); + assert_eq!( + result, + serde_json::from_str(&result.to_json().expect("diagnostic JSON")) + .expect("diagnostic JSON round-trip") + ); + assert!(!result + .to_json() + .expect("diagnostic JSON") + .contains("postgres://")); + assert_eq!( + result.canonical_bytes().expect("canonical result"), + result.canonical_bytes().expect("second canonical result") + ); + + let decoded: ContractDiagnostic = serde_json::from_value(serde_json::json!({ + "code": "CTL-SCHEMA-DRIFT", + "scope": "secret=scope", + "owner": "postgres://user:password@example.invalid/db", + "source_paths": ["token=source"], + "derived_paths": ["secret=derived"], + "semantic_path": "password=semantic", + "expected": "secret=expected", + "observed": "Bearer observed-token", + "required_classification": "token=classification", + "repair_command": "secret=repair", + "detail": "password=detail" + })) + .expect("diagnostic JSON round-trip"); + assert!(!decoded.human().contains("postgres://")); + assert!(!decoded.human().contains("password=")); + assert!(!decoded.human().contains("token=")); + + let mut directly_mutated = migration; + directly_mutated.scope = Some("secret=scope".to_string()); + directly_mutated.owner = "postgres://user:password@example.invalid/db".to_string(); + directly_mutated + .source_paths + .insert("token=source".to_string()); + directly_mutated + .derived_paths + .insert("secret=derived".to_string()); + directly_mutated.semantic_path = Some("password=semantic".to_string()); + directly_mutated.required_classification = Some("token=classification".to_string()); + directly_mutated.repair_command = "secret=repair".to_string(); + directly_mutated.detail = "password=detail".to_string(); + + let direct_human = directly_mutated.human(); + let direct_json = serde_json::to_string(&directly_mutated).expect("direct diagnostic JSON"); + let direct_debug = format!("{directly_mutated:?}"); + assert!(!direct_human.contains("postgres://")); + assert!(!direct_human.contains("password=")); + assert!(!direct_human.contains("token=")); + assert!(!direct_json.contains("postgres://")); + assert!(!direct_json.contains("password=")); + assert!(!direct_json.contains("token=")); + assert!(!direct_debug.contains("postgres://")); + assert!(!direct_debug.contains("password=")); + assert!(!direct_debug.contains("token=")); +} + +#[test] +fn client_inventory_canonicalizes_client_and_document_order() { + let inventory = ClientInventory::from_path( + repository_root().join("tests/e2e-ui/ui/distributed.clients.json"), + ) + .expect("client inventory"); + let mut reversed = inventory.clone(); + reversed.clients.reverse(); + assert_eq!( + inventory.canonical_bytes().expect("canonical inventory"), + reversed + .canonical_bytes() + .expect("reversed canonical inventory") + ); +} + +#[test] +fn canonical_bytes_reject_direct_public_secret_mutation() { + let mut catalog = ContractCatalog::from_json_str(fixture("valid")).expect("valid catalog"); + catalog + .entries + .get_mut("application-manifest") + .expect("application entry") + .identity + .value = "postgres://user:password@example.invalid/db".to_string(); + let catalog_error = catalog + .canonical_bytes() + .expect_err("catalog serialization must validate public mutations"); + assert_eq!( + catalog_error.code(), + ContractDiagnosticCode::EnvironmentValue + ); + + let mut inventory = ClientInventory::from_json_str(include_str!( + "../../../tests/e2e-ui/ui/distributed.clients.json" + )) + .expect("valid client inventory"); + inventory.clients[0] + .documents + .insert("src/routes/token=secret.graphql".to_string()); + let inventory_error = inventory + .canonical_bytes() + .expect_err("client serialization must validate public mutations"); + assert_eq!( + inventory_error.code(), + ContractDiagnosticCode::EnvironmentValue + ); +} + +#[test] +fn check_and_aggregate_renderers_do_not_leak_mutated_artifact_identity() { + let mut catalog = ContractCatalog::from_json_str(fixture("valid")).expect("valid catalog"); + catalog + .entries + .get_mut("application-manifest") + .expect("application entry") + .identity + .value = "postgres://user:password@example.invalid/db".to_string(); + + let checked = catalog.check(repository_root()); + assert!(checked.artifacts.is_empty()); + let checked_json = checked.to_json().expect("checked JSON"); + let checked_canonical = + String::from_utf8(checked.canonical_bytes().expect("checked canonical JSON")) + .expect("checked JSON is UTF-8"); + let checked_debug = format!("{checked:?}"); + assert!(!checked_json.contains("postgres://")); + assert!(!checked_canonical.contains("postgres://")); + assert!(!checked_debug.contains("postgres://")); + + let mut directly_mutated = ContractCheckResult { + catalog_identity: Some("token=identity".to_string()), + ..Default::default() + }; + directly_mutated.artifacts.insert( + "secret=entry".to_string(), + ArtifactIdentity::new( + ContractArtifactKind::ApplicationManifest, + "password=artifact", + ), + ); + let direct_json = directly_mutated.to_json().expect("aggregate JSON"); + let direct_canonical = String::from_utf8( + directly_mutated + .canonical_bytes() + .expect("aggregate canonical JSON"), + ) + .expect("aggregate JSON is UTF-8"); + let direct_debug = format!("{directly_mutated:?}"); + assert!(!direct_json.contains("token=identity")); + assert!(!direct_json.contains("secret=entry")); + assert!(!direct_json.contains("password=artifact")); + assert!(!direct_canonical.contains("token=identity")); + assert!(!direct_canonical.contains("secret=entry")); + assert!(!direct_canonical.contains("password=artifact")); + assert!(!direct_debug.contains("token=identity")); + assert!(!direct_debug.contains("secret=entry")); + assert!(!direct_debug.contains("password=artifact")); +} + +#[test] +fn client_inventory_rust_schema_matches_shared_parity_vectors() { + let vectors: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/contracts/client-inventory-parity.json" + )) + .expect("client inventory parity JSON"); + for vector in vectors["vectors"].as_array().expect("parity vectors") { + let name = vector["name"].as_str().expect("parity vector name"); + let expected = vector["valid"].as_bool().expect("parity vector result"); + let input = serde_json::to_string(&vector["inventory"]).expect("parity inventory JSON"); + assert_eq!( + ClientInventory::from_json_str(&input).is_ok(), + expected, + "Rust client inventory parity vector `{name}`" + ); + } +} + +#[test] +fn migration_inventory_rejects_missing_extra_and_dialect_drift() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-vectors"); + let inventory = MigrationInventory::from_repository_root(root.path()) + .expect("valid migration fixture inventory"); + assert_eq!(inventory.migrations[0].version, 1); + + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut missing_dialect: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + missing_dialect["migrations"][0] + .as_object_mut() + .expect("migration object") + .remove("sqlite"); + let missing_dialect_error = MigrationInventory::from_json_str( + &serde_json::to_string(&missing_dialect).expect("missing dialect JSON"), + ) + .expect_err("missing dialect must fail"); + assert_eq!( + missing_dialect_error.code(), + ContractDiagnosticCode::MigrationInventory + ); + + fs::remove_file(root.path().join("migrations/sqlite/0001_initial.sql")) + .expect("remove declared migration"); + let missing_file = check_migration_inventory(root.path()); + assert!(!missing_file.is_ok()); + assert!(missing_file.human().contains("CTL-MIG-INVENTORY")); + assert!(missing_file + .human() + .contains("migrations/sqlite/0001_initial.sql")); + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + ); + + write_repository_file( + root.path(), + "migrations/sqlite/0002_extra.sql", + b"CREATE TABLE extra (id INTEGER);\n", + ); + let extra_file = check_migration_inventory(root.path()); + assert!(!extra_file.is_ok()); + assert!(extra_file + .human() + .contains("migrations/sqlite/0002_extra.sql")); + fs::remove_file(root.path().join("migrations/sqlite/0002_extra.sql")) + .expect("remove extra migration"); + + let mut dialect_drift: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + dialect_drift["migrations"][0]["postgres"]["path"] = + Value::String("migrations/mysql/0001_initial.sql".to_string()); + let dialect_drift_error = MigrationInventory::from_json_str( + &serde_json::to_string(&dialect_drift).expect("dialect drift JSON"), + ) + .expect_err("dialect drift must fail"); + assert_eq!( + dialect_drift_error.code(), + ContractDiagnosticCode::MigrationInventory + ); + assert!(dialect_drift_error + .message() + .contains("migrations/mysql/0001_initial.sql")); + assert_eq!( + migration_checksum(&postgres_sql), + inventory.migrations[0].postgres.sha256 + ); +} + +#[test] +fn migration_inventory_rejects_duplicate_and_non_consecutive_versions() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-order"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut non_consecutive: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + non_consecutive["migrations"][0]["version"] = Value::from(2_u64); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&non_consecutive).expect("non-consecutive JSON"), + ) + .expect_err("non-consecutive version must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + + let mut duplicate = + serde_json::from_slice::(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + duplicate["migrations"] = json!([ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + ), + migration_entry( + 1, + "migrations/sqlite/0002_duplicate.sql", + &sqlite_sql, + "migrations/postgres/0002_duplicate.sql", + &postgres_sql, + ) + ]); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&duplicate).expect("duplicate JSON"), + ) + .expect_err("duplicate version must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); +} + +#[test] +fn migration_inventory_rejects_checksum_path_and_symlink_mutations() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-security"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut bad_checksum: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + bad_checksum["migrations"][0]["sqlite"]["sha256"] = Value::String("0".repeat(64)); + write_json_value(&inventory_path, &bad_checksum); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("checksum mutation must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("0001_initial.sql")); + + let valid = migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + ); + write_migration_inventory(root.path(), std::slice::from_ref(&valid)); + let mut bad_path: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + bad_path["migrations"][0]["sqlite"]["path"] = + Value::String("migrations/sqlite/../outside.sql".to_string()); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&bad_path).expect("path mutation JSON"), + ) + .expect_err("path traversal must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + + bad_path["migrations"][0]["sqlite"]["path"] = Value::String( + root.path() + .join("outside.sql") + .to_string_lossy() + .into_owned(), + ); + let error = MigrationInventory::from_json_str( + &serde_json::to_string(&bad_path).expect("absolute path JSON"), + ) + .expect_err("absolute path must fail without exposing the machine path"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); + + #[cfg(unix)] + { + write_migration_inventory(root.path(), std::slice::from_ref(&valid)); + fs::remove_file(root.path().join("migrations/sqlite/0001_initial.sql")) + .expect("remove migration for symlink fixture"); + let outside = root.path().join("outside.sql"); + fs::write(&outside, &sqlite_sql).expect("outside SQL"); + symlink( + &outside, + root.path().join("migrations/sqlite/0001_initial.sql"), + ) + .expect("migration symlink"); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("symlink migration must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSymlinkEscape); + } + + let declared_path = root.path().join("migrations/sqlite/0001_initial.sql"); + if declared_path.is_symlink() { + fs::remove_file(&declared_path).expect("remove migration symlink"); + } else if declared_path.exists() { + fs::remove_file(&declared_path).expect("remove migration file for special-file fixture"); + } + fs::create_dir(&declared_path).expect("migration special-file fixture"); + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("directory at declared SQL path must fail"); + assert_eq!(error.code(), ContractDiagnosticCode::CatalogSpecialFile); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); +} + +#[test] +fn migration_inventory_redacts_sensitive_and_non_normal_paths() { + let (root, sqlite_sql, postgres_sql) = create_migration_fixture("inventory-path-redaction"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let unique_secret = "migration-path-secret-7f5e1c9b"; + let mut sensitive: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory")) + .expect("inventory value"); + sensitive["migrations"][0]["sqlite"]["path"] = + Value::String(format!("migrations/sqlite/password={unique_secret}.sql")); + let sensitive_json = serde_json::to_string(&sensitive).expect("sensitive path JSON"); + let error = MigrationInventory::from_json_str(&sensitive_json) + .expect_err("credential-like path must fail safely"); + for rendered in [ + error.message().to_string(), + error.to_string(), + format!("{error:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive path leaked from error rendering: {rendered}" + ); + } + write_json_value(&inventory_path, &sensitive); + let checked = check_migration_inventory(root.path()); + for rendered in [ + checked.human(), + checked.to_json().expect("sensitive diagnostic JSON"), + format!("{checked:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive path leaked from diagnostic rendering: {rendered}" + ); + } + + let traversal_sentinel = "migration-traversal-sentinel-3c2a8e1d"; + let mut traversal = sensitive; + traversal["migrations"][0]["sqlite"]["path"] = + Value::String(format!("migrations/sqlite/../{traversal_sentinel}.sql")); + let traversal_error = MigrationInventory::from_json_str( + &serde_json::to_string(&traversal).expect("traversal path JSON"), + ) + .expect_err("non-normal path must fail safely"); + assert!(!traversal_error.message().contains(traversal_sentinel)); + assert!(!format!("{traversal_error:?}").contains(traversal_sentinel)); + + let safe_relative = "migrations/sqlite/safe-relative-diagnostic.sql"; + traversal["migrations"][0]["sqlite"]["path"] = Value::String(safe_relative.to_string()); + MigrationInventory::from_json_str( + &serde_json::to_string(&traversal).expect("safe relative path JSON"), + ) + .expect("safe relative path should remain structurally valid"); + write_json_value(&inventory_path, &traversal); + let safe_error = MigrationInventory::from_repository_root(root.path()) + .expect_err("missing safe relative path must be reported"); + assert!(safe_error.message().contains(safe_relative)); + let safe_checked = check_migration_inventory(root.path()); + assert!(safe_checked.human().contains(safe_relative)); + assert!(safe_checked + .to_json() + .expect("safe relative diagnostic JSON") + .contains(safe_relative)); + + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &sqlite_sql, + "migrations/postgres/0001_initial.sql", + &postgres_sql, + )], + ); + write_repository_file( + root.path(), + &format!("migrations/sqlite/password={unique_secret}.sql"), + b"CREATE TABLE secret_path (id INTEGER);\n", + ); + let extra_checked = check_migration_inventory(root.path()); + for rendered in [ + extra_checked.human(), + extra_checked.to_json().expect("extra diagnostic JSON"), + format!("{extra_checked:?}"), + ] { + assert!( + !rendered.contains(unique_secret), + "sensitive extra path leaked from diagnostic rendering: {rendered}" + ); + } +} + +#[test] +fn migration_inventory_rejects_json_nesting_before_parse_and_ignores_strings() { + let nested_json = |count: usize| format!("{}0{}", "[".repeat(count), "]".repeat(count)); + let at_limit = format!( + r#"{{"schema_version":1,"migrations":[],"padding":{}}}"#, + nested_json(MAX_MIGRATION_JSON_DEPTH - 1) + ); + let at_limit_error = MigrationInventory::from_json_str(&at_limit) + .expect_err("the configured JSON nesting limit should reach serde"); + assert!(!at_limit_error + .message() + .contains("maximum JSON nesting depth")); + + let over_limit = format!( + r#"{{"schema_version":1,"migrations":[],"padding":{}}}"#, + nested_json(MAX_MIGRATION_JSON_DEPTH) + ); + let over_limit_error = MigrationInventory::from_json_str(&over_limit) + .expect_err("JSON beyond the configured nesting limit must fail"); + assert!(over_limit_error + .message() + .contains("maximum JSON nesting depth")); + + let string_delimiters = r#"{"schema_version":1,"migrations":[],"padding":"braces {[ ]} and an escaped quote: \" plus [nested]"}"#; + let string_error = MigrationInventory::from_json_str(string_delimiters) + .expect_err("unknown string field should fail structural parsing"); + assert!(!string_error + .message() + .contains("maximum JSON nesting depth")); +} + +#[test] +fn migration_inventory_rejects_total_and_top_level_entry_floods() { + let (root, _, _) = create_migration_fixture("entry-flood"); + for index in 0..=MAX_MIGRATION_TOTAL_ENTRIES { + write_repository_file( + root.path(), + &format!("migrations/sqlite/noise-{index:04}.txt"), + b"not SQL", + ); + } + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("non-SQL entries must count toward the dialect traversal bound"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("entries")); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); + + let (root, _, _) = create_migration_fixture("top-level-entry-flood"); + for index in 0..=MAX_MIGRATION_TOP_LEVEL_ENTRIES { + write_repository_file( + root.path(), + &format!("migrations/top-level-noise-{index:03}.txt"), + b"ignored top-level file", + ); + } + let error = MigrationInventory::from_repository_root(root.path()) + .expect_err("top-level migrations entries must be bounded"); + assert_eq!(error.code(), ContractDiagnosticCode::MigrationInventory); + assert!(error.message().contains("migrations directory")); + assert!(!error + .message() + .contains(&root.path().to_string_lossy().to_string())); +} + +#[test] +fn migration_inventory_directory_diagnostics_are_deterministic() { + let (root, _, _) = create_migration_fixture("directory-order"); + fs::create_dir(root.path().join("migrations/zzz-invalid")) + .expect("late invalid dialect directory"); + fs::create_dir(root.path().join("migrations/aaa-invalid")) + .expect("early invalid dialect directory"); + + let first = check_migration_inventory(root.path()); + let second = check_migration_inventory(root.path()); + assert_eq!(first.human(), second.human()); + assert_eq!( + first.to_json().expect("first diagnostic JSON"), + second.to_json().expect("second diagnostic JSON") + ); + assert!(first.human().contains("migrations/aaa-invalid")); + assert!(!first.human().contains("migrations/zzz-invalid")); +} + +fn write_json_value(path: &Path, value: &Value) { + fs::write( + path, + serde_json::to_vec_pretty(value).expect("JSON value serialization"), + ) + .expect("JSON value file"); +} + +#[test] +fn migration_inventory_is_deterministic_and_preserves_runtime_order() { + let inventory = MigrationInventory::from_repository_root(repository_root()) + .expect("repository migration inventory"); + let versions = inventory + .migrations + .iter() + .map(|migration| migration.version) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + inventory.canonical_bytes().expect("canonical inventory"), + inventory + .canonical_bytes() + .expect("second canonical inventory") + ); +} + +#[test] +fn released_v3_3_4_migration_fixture_matches_provenance() { + let baseline: Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/migrations/v3.3.4/baseline.json" + )) + .expect("released migration baseline JSON"); + assert_eq!(baseline["release_tag"], "v3.3.4"); + assert_eq!( + baseline["revision"], + "f57543ddeb9e293cf366dba1a2330b34ce6509f0" + ); + assert_eq!(baseline["migration_count"], 1); + assert_eq!( + baseline["migrations"][0]["sqlite"]["sha256"], + migration_checksum(include_bytes!( + "../../../tests/fixtures/migrations/v3.3.4/sqlite/0001_initial.sql" + )) + ); + assert_eq!( + baseline["migrations"][0]["postgres"]["sha256"], + migration_checksum(include_bytes!( + "../../../tests/fixtures/migrations/v3.3.4/postgres/0001_initial.sql" + )) + ); +} + +#[test] +fn baseline_history_rejects_coordinated_sql_and_checksum_edits() { + let (root, base_sqlite, base_postgres) = create_migration_fixture("history-edit"); + let base_revision = commit_migration_fixture(root.path()); + + let mutated_sqlite = b"CREATE TABLE one (id INTEGER PRIMARY KEY, changed INTEGER);\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + mutated_sqlite, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + mutated_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("coordinated local checksum edit remains current-tree valid"); + let refused = current.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused + .human() + .contains("migrations/sqlite/0001_initial.sql")); + assert!(refused.human().contains("add migration 2")); + + let migration_two_sqlite = b"ALTER TABLE one ADD COLUMN added INTEGER;\n"; + let migration_two_postgres = b"ALTER TABLE one ADD COLUMN added BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + ); + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + migration_two_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + migration_two_postgres, + ); + write_migration_inventory( + root.path(), + &[ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_next.sql", + migration_two_sqlite, + "migrations/postgres/0002_next.sql", + migration_two_postgres, + ), + ], + ); + let repaired = MigrationInventory::from_repository_root(root.path()) + .expect("restored baseline plus new migration inventory"); + let accepted = repaired.check_history(root.path(), &base_revision); + assert!(accepted.is_verified(), "{}", accepted.human()); +} + +#[test] +fn baseline_history_rejects_description_delete_renumber_and_reorder() { + let (root, first_sqlite, first_postgres) = create_migration_fixture("history-vectors"); + let second_sqlite = b"ALTER TABLE one ADD COLUMN added INTEGER;\n"; + let second_postgres = b"ALTER TABLE one ADD COLUMN added BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + second_postgres, + ); + let baseline_entries = vec![ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &first_sqlite, + "migrations/postgres/0001_initial.sql", + &first_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_next.sql", + second_sqlite, + "migrations/postgres/0002_next.sql", + second_postgres, + ), + ]; + write_migration_inventory(root.path(), &baseline_entries); + let base_revision = commit_migration_fixture(root.path()); + let baseline = MigrationInventory::from_repository_root(root.path()) + .expect("two-migration baseline inventory"); + + let mut description = baseline.clone(); + description.migrations[0].description = "edited".to_string(); + let refused = description.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("description changed")); + + fs::remove_file(root.path().join("migrations/sqlite/0002_next.sql")) + .expect("remove deleted sqlite migration"); + fs::remove_file(root.path().join("migrations/postgres/0002_next.sql")) + .expect("remove deleted postgres migration"); + let mut deleted = baseline.clone(); + deleted.migrations.pop(); + let refused = deleted.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("baseline migration 2 was deleted")); + assert!(refused.human().contains("add migration 3")); + + write_repository_file( + root.path(), + "migrations/sqlite/0002_next.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_next.sql", + second_postgres, + ); + let mut renumbered = baseline.clone(); + renumbered.migrations[1].version = 3; + let refused = renumbered.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused.human().contains("order or numbering changed")); + + let mut reordered = baseline; + reordered.migrations.swap(0, 1); + let refused = reordered.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("CTL-MIG-HISTORY")); + assert!(refused.human().contains("order or numbering changed")); +} + +#[test] +fn baseline_newer_deletion_repair_never_reuses_immutable_version() { + let (root, first_sqlite, first_postgres) = create_migration_fixture("history-newer"); + let second_sqlite = b"ALTER TABLE one ADD COLUMN second INTEGER;\n"; + let second_postgres = b"ALTER TABLE one ADD COLUMN second BIGINT;\n"; + let third_sqlite = b"ALTER TABLE one ADD COLUMN third INTEGER;\n"; + let third_postgres = b"ALTER TABLE one ADD COLUMN third BIGINT;\n"; + write_repository_file( + root.path(), + "migrations/sqlite/0002_second.sql", + second_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0002_second.sql", + second_postgres, + ); + write_repository_file( + root.path(), + "migrations/sqlite/0003_third.sql", + third_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0003_third.sql", + third_postgres, + ); + let baseline_entries = vec![ + migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &first_sqlite, + "migrations/postgres/0001_initial.sql", + &first_postgres, + ), + migration_entry( + 2, + "migrations/sqlite/0002_second.sql", + second_sqlite, + "migrations/postgres/0002_second.sql", + second_postgres, + ), + migration_entry( + 3, + "migrations/sqlite/0003_third.sql", + third_sqlite, + "migrations/postgres/0003_third.sql", + third_postgres, + ), + ]; + write_migration_inventory(root.path(), &baseline_entries); + let base_revision = commit_migration_fixture(root.path()); + let baseline = + MigrationInventory::from_repository_root(root.path()).expect("newer baseline inventory"); + + fs::remove_file(root.path().join("migrations/sqlite/0003_third.sql")) + .expect("remove deleted third sqlite migration"); + fs::remove_file(root.path().join("migrations/postgres/0003_third.sql")) + .expect("remove deleted third postgres migration"); + let mut deleted = baseline.clone(); + deleted.migrations.pop(); + let refused = deleted.check_history(root.path(), &base_revision); + assert!(!refused.is_verified()); + assert!(refused.human().contains("baseline migration 3 was deleted")); + assert!(refused.human().contains("add migration 4")); + assert!(!refused.human().contains("add migration 3")); +} + +#[test] +fn oversized_git_baseline_object_is_unavailable_without_leaking_contents() { + let (root, base_sqlite, base_postgres) = create_migration_fixture("history-oversized"); + let oversized_sql = vec![b'x'; MAX_MIGRATION_SQL_BYTES + 1]; + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &oversized_sql, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &oversized_sql, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let base_revision = commit_migration_fixture(root.path()); + + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + &base_sqlite, + "migrations/postgres/0001_initial.sql", + &base_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("current tree remains valid after oversized baseline"); + let refused = current.check_history(root.path(), &base_revision); + assert!(refused.is_unavailable()); + assert!(!refused.is_verified()); + assert!(refused + .baseline + .reason() + .expect("oversized baseline reason") + .contains("exceeds")); + let human = refused.human(); + assert!(human.contains("history evidence unavailable")); + assert!(!human.contains("xxxxxxxx")); + + let (root, current_sqlite, current_postgres) = + create_migration_fixture("history-inventory-limit"); + let inventory_path = root.path().join(MIGRATION_INVENTORY_PATH); + let mut oversized_inventory: Value = + serde_json::from_slice(&fs::read(&inventory_path).expect("read inventory baseline")) + .expect("inventory baseline JSON"); + oversized_inventory["padding"] = Value::String("y".repeat(MAX_MIGRATION_INVENTORY_BYTES)); + write_json_value(&inventory_path, &oversized_inventory); + let inventory_base_revision = commit_migration_fixture(root.path()); + write_repository_file( + root.path(), + "migrations/sqlite/0001_initial.sql", + ¤t_sqlite, + ); + write_repository_file( + root.path(), + "migrations/postgres/0001_initial.sql", + ¤t_postgres, + ); + write_migration_inventory( + root.path(), + &[migration_entry( + 1, + "migrations/sqlite/0001_initial.sql", + ¤t_sqlite, + "migrations/postgres/0001_initial.sql", + ¤t_postgres, + )], + ); + let current = MigrationInventory::from_repository_root(root.path()) + .expect("current tree remains valid after oversized inventory baseline"); + let refused = current.check_history(root.path(), &inventory_base_revision); + assert!(refused.is_unavailable()); + assert!(refused + .baseline + .reason() + .expect("oversized inventory baseline reason") + .contains("exceeds")); +} + +#[test] +fn unavailable_merge_base_is_reported_not_verified() { + let root = repository_root(); + let inventory = + MigrationInventory::from_repository_root(&root).expect("repository migration inventory"); + let result = inventory.check_history(&root, "missing-merge-base-for-test"); + assert!(result.is_unavailable()); + assert!(!result.is_verified()); + assert!(matches!( + result.baseline, + BaselineAvailability::Unavailable { .. } + )); + assert!(result.human().contains("history evidence unavailable")); + assert!(result.human().contains("merge_base_available=false")); +} diff --git a/distributed_cli/src/contracts/transaction.rs b/distributed_cli/src/contracts/transaction.rs new file mode 100644 index 00000000..cdd6b664 --- /dev/null +++ b/distributed_cli/src/contracts/transaction.rs @@ -0,0 +1,318 @@ +//! Read-only contract check and exact-scope accept transactions. +//! +//! `check` never writes tracked files. `accept` stages outputs, replaces +//! atomically on success, and restores the prior set on failure. + +use super::catalog::ContractCatalog; +use super::chain::{check_predecessor_chain, ObservedPredecessor}; +use super::diagnostic::{ + ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode, +}; +use super::ContractArtifactKind; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Exact accept scopes supported by the aggregate CLI. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractAcceptScope { + Catalog, + MigrationInventory, + SurfaceClientManifest, + GeneratedClientTree, + ApplicationManifest, + DeploymentPlan, + /// Exact client-program scope: `program:`. + Program { id: String }, +} + +impl ContractAcceptScope { + pub fn parse(value: &str) -> Option { + match value { + "catalog" => Some(Self::Catalog), + "migration_inventory" => Some(Self::MigrationInventory), + "surface_client_manifest" => Some(Self::SurfaceClientManifest), + "generated_client_tree" => Some(Self::GeneratedClientTree), + "application_manifest" => Some(Self::ApplicationManifest), + "deployment_plan" => Some(Self::DeploymentPlan), + other if other.starts_with("program:") => { + let id = other.trim_start_matches("program:").to_string(); + if id.is_empty() { + None + } else { + Some(Self::Program { id }) + } + } + _ => None, + } + } + + pub fn as_str(&self) -> String { + match self { + Self::Catalog => "catalog".into(), + Self::MigrationInventory => "migration_inventory".into(), + Self::SurfaceClientManifest => "surface_client_manifest".into(), + Self::GeneratedClientTree => "generated_client_tree".into(), + Self::ApplicationManifest => "application_manifest".into(), + Self::DeploymentPlan => "deployment_plan".into(), + Self::Program { id } => format!("program:{id}"), + } + } +} + +/// Result of a read-only aggregate check. +#[derive(Clone, Debug, Serialize)] +pub struct ContractsCheckReport { + pub ok: bool, + pub result: ContractCheckResult, + pub human: String, +} + +/// Result of an exact-scope accept transaction. +#[derive(Clone, Debug, Serialize)] +pub struct ContractsAcceptReport { + pub ok: bool, + pub scope: String, + pub changed_paths: Vec, + pub noop: bool, + pub rolled_back: bool, + pub diagnostics: Vec, +} + +/// Aggregate read-only contracts check over a catalog root. +pub fn contracts_check( + catalog: &ContractCatalog, + root: &Path, + predecessors: impl IntoIterator, +) -> ContractsCheckReport { + let mut result = catalog.check(root); + let chain = check_predecessor_chain(predecessors); + result.diagnostics.extend(chain.diagnostics); + let ok = result.diagnostics.is_empty(); + let human = result + .diagnostics + .iter() + .map(|diagnostic| diagnostic.human()) + .collect::>() + .join("\n"); + ContractsCheckReport { ok, result, human } +} + +/// Accept one exact scope by replacing declared relative paths under `root`. +/// +/// `staged` maps portable relative paths to their new bytes. Paths must stay +/// inside `root` after physical resolution. On any failure the prior contents +/// of every touched path are restored. +pub fn contracts_accept( + root: &Path, + scope: ContractAcceptScope, + staged: &BTreeMap>, +) -> Result { + if staged.is_empty() { + return Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: Vec::new(), + noop: true, + rolled_back: false, + diagnostics: Vec::new(), + }); + } + + let mut resolved = BTreeMap::new(); + for (relative, bytes) in staged { + let path = resolve_under_root(root, relative)?; + resolved.insert(relative.clone(), (path, bytes.clone())); + } + + // Detect no-op: every path already has identical bytes. + let mut noop = true; + for (path, bytes) in resolved.values() { + match fs::read(path) { + Ok(existing) if existing == *bytes => {} + _ => { + noop = false; + break; + } + } + } + if noop { + return Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: Vec::new(), + noop: true, + rolled_back: false, + diagnostics: Vec::new(), + }); + } + + // Snapshot prior contents for rollback. + let mut prior = BTreeMap::new(); + for (relative, (path, _)) in &resolved { + prior.insert( + relative.clone(), + if path.exists() { + Some(fs::read(path).map_err(|error| error.to_string())?) + } else { + None + }, + ); + } + + let mut changed = Vec::new(); + for (relative, (path, bytes)) in &resolved { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let staging = path.with_extension("distributed-accept-staging"); + if let Err(error) = fs::write(&staging, bytes) { + let _ = rollback(root, &prior); + return Err(error.to_string()); + } + if let Err(error) = fs::rename(&staging, path) { + let _ = fs::remove_file(&staging); + let _ = rollback(root, &prior); + return Err(error.to_string()); + } + changed.push(relative.clone()); + } + + Ok(ContractsAcceptReport { + ok: true, + scope: scope.as_str(), + changed_paths: changed, + noop: false, + rolled_back: false, + diagnostics: Vec::new(), + }) +} + +fn rollback( + root: &Path, + prior: &BTreeMap>>, +) -> Result<(), String> { + for (relative, contents) in prior { + let path = resolve_under_root(root, relative)?; + match contents { + Some(bytes) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&path, bytes).map_err(|e| e.to_string())?; + } + None if path.exists() => { + fs::remove_file(&path).map_err(|e| e.to_string())?; + } + None => {} + } + } + Ok(()) +} + +fn resolve_under_root(root: &Path, relative: &str) -> Result { + if relative.is_empty() + || relative.starts_with('/') + || relative.contains('\0') + || Path::new(relative) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!( + "accept path `{relative}` escapes or is not a portable relative path" + )); + } + let root = root + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()); + let candidate = root.join(relative); + if let Ok(canonical) = candidate.canonicalize() { + if !canonical.starts_with(&root) { + return Err(format!( + "accept path `{relative}` resolves outside the catalog root" + )); + } + return Ok(canonical); + } + // Path may not exist yet — validate parents. + if let Some(parent) = candidate.parent() { + if parent.exists() { + let parent = parent.canonicalize().map_err(|e| e.to_string())?; + if !parent.starts_with(&root) { + return Err(format!( + "accept path `{relative}` parent resolves outside the catalog root" + )); + } + } + } + Ok(candidate) +} + +/// Build a diagnostic when an accept scope is unknown or broad. +pub fn unknown_scope_diagnostic(scope: &str) -> ContractDiagnostic { + ContractDiagnostic::new( + ContractDiagnosticCode::CatalogInvalid, + Some(ContractArtifactKind::ApplicationManifest), + None::<&str>, + "contracts.accept", + std::iter::empty::<&str>(), + std::iter::empty::<&str>(), + Some("scope"), + Some("exact catalog-owned scope"), + Some(scope), + Some("use_exact_scope"), + None, + "distributed contracts accept --scope ", + ) + .with_detail(format!("unknown or broad accept scope `{scope}`")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("distributed-contracts-accept-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn accept_is_idempotent_and_rolls_back_on_escape() { + let root = temp_root(); + let mut staged = BTreeMap::new(); + staged.insert("contracts/app.json".into(), b"{\"ok\":true}".to_vec()); + let first = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged) + .expect("first accept"); + assert!(first.ok); + assert!(!first.noop); + assert_eq!(first.changed_paths, vec!["contracts/app.json".to_string()]); + + let second = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged) + .expect("second accept"); + assert!(second.noop); + + let mut bad = BTreeMap::new(); + bad.insert("../escape.json".into(), b"nope".to_vec()); + assert!(contracts_accept(&root, ContractAcceptScope::Catalog, &bad).is_err()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn check_report_is_no_write() { + // Empty catalog check shape only — catalog construction is covered elsewhere. + let report = ContractsCheckReport { + ok: true, + result: ContractCheckResult::default(), + human: String::new(), + }; + assert!(report.ok); + } +} diff --git a/distributed_cli/src/generate/mod.rs b/distributed_cli/src/generate/mod.rs index 189cb404..9dec9a5c 100644 --- a/distributed_cli/src/generate/mod.rs +++ b/distributed_cli/src/generate/mod.rs @@ -601,7 +601,7 @@ mod tests { assert!(main.contains("tracer_provider.shutdown()")); let service = contents(&project, "src/service.rs"); - assert!(service.contains(".tracing(TracingManifest::otlp())")); + assert!(service.contains(".tracing(TracingDescriptor::otlp())")); let values = contents(&project, ".gitops/deploy/values.yaml"); assert!(values.contains("enabled: true")); diff --git a/distributed_cli/src/generate/service_crate.rs b/distributed_cli/src/generate/service_crate.rs index 4de045aa..e28a99b4 100644 --- a/distributed_cli/src/generate/service_crate.rs +++ b/distributed_cli/src/generate/service_crate.rs @@ -96,7 +96,7 @@ tokio = {{ version = "1", features = ["macros", "net", "rt-multi-thread"] }} pub mod manifest; {models}{read_models}{query}pub mod service; -pub use manifest::distributed_manifest; +pub use manifest::{{application_manifest, read_model_catalog, service_descriptor}}; "# ) } @@ -205,17 +205,33 @@ async fn main() -> Result<(), {error_type}> {{ .map(|model| format!(" .read_model::<{}>()\n", model.view_ident)) .collect::(); format!( - r#"use distributed::{{ - DistributedProjectManifest, ServiceManifest, +r#"use distributed::{{ + Application, ApplicationManifest, ReadModelCatalog, SurfaceSpec, }}; -{read_model_import}pub fn distributed_manifest() -> DistributedProjectManifest {{ - DistributedProjectManifest::new({project_name}) -{read_model_registration} .service(crate::service::manifest()) +{read_model_import}pub fn application_manifest() -> ApplicationManifest {{ + let catalog = read_model_catalog(); + let surface = distributed::graphql::build_surface( + &catalog.tables, + &distributed::graphql::SurfaceOptions::sqlite(), + ) + .expect("read-model catalog should compile into an application Surface"); + let surface = SurfaceSpec::from_surface({project_name}, &surface) + .expect("application Surface contract should be portable"); + Application::new({project_name}) + .surface(surface) + .build() + .expect("application manifest should compile") + .manifest() + .clone() }} -pub fn service_manifest() -> ServiceManifest {{ - crate::service::manifest() +pub fn read_model_catalog() -> ReadModelCatalog {{ + ReadModelCatalog::new({project_name}) +{read_model_registration}}} + +pub fn service_descriptor() -> distributed::microsvc::ServiceDescriptor {{ + crate::service::descriptor() }} "#, project_name = rust_string(&self.names.package_name), @@ -234,13 +250,13 @@ pub fn service_manifest() -> ServiceManifest {{ let mut manifest_imports = vec![ "microsvc::{Routes, Service}", repo_import, - "ServiceManifest", + "ServiceDescriptor", ]; if self.metrics == Some(MetricsTarget::Prometheus) { - manifest_imports.push("MetricsEndpointManifest"); + manifest_imports.push("MetricsEndpointDescriptor"); } if self.tracing { - manifest_imports.push("TracingManifest"); + manifest_imports.push("TracingDescriptor"); } if self.query_api && !self.commands.is_empty() { manifest_imports.push("AggregateRepository"); @@ -316,12 +332,12 @@ pub fn service_manifest() -> ServiceManifest {{ ServiceTransport::Knative => "knative", }; let manifest_metrics = if self.metrics == Some(MetricsTarget::Prometheus) { - " .metrics(MetricsEndpointManifest::prometheus_default())\n" + " .metrics(MetricsEndpointDescriptor::prometheus_default())\n" } else { "" }; let manifest_tracing = if self.tracing { - " .tracing(TracingManifest::otlp())\n" + " .tracing(TracingDescriptor::otlp())\n" } else { "" }; @@ -411,8 +427,8 @@ pub async fn build_with_graphql() -> Result, Box ServiceManifest {{ - ServiceManifest::new({service_name}) +pub fn descriptor() -> ServiceDescriptor {{ + ServiceDescriptor::new({service_name}) {manifest_commands}{manifest_events}{manifest_metrics}{manifest_tracing} .transport({transport}) }} "#, @@ -441,8 +457,8 @@ pub fn build(repo: ServiceRepo) -> Arc {{ Arc::new(Service::new().named({service_name}).routes(routes)) }} -pub fn manifest() -> ServiceManifest {{ - ServiceManifest::new({service_name}) +pub fn descriptor() -> ServiceDescriptor {{ + ServiceDescriptor::new({service_name}) {manifest_commands}{manifest_events}{manifest_metrics}{manifest_tracing} .transport({transport}) }} "#, @@ -779,7 +795,7 @@ pub fn build_engine( // issuer (still OidcBearer; ambient headers never trusted). For local // GraphiQL ambient headers only, pass IdentityMode::DevHeaders explicitly. let identity = distributed::graphql::public_oidc_identity_from_env(); - GraphqlEngine::from_manifest(&crate::distributed_manifest(), source)? + GraphqlEngine::from_schema_catalog(&crate::read_model_catalog(), source)? .service(service){protocol_key_builder} .roles(roles::ALL) .grant_all(roles::USER) diff --git a/distributed_cli/src/lib.rs b/distributed_cli/src/lib.rs index b55cd15a..8c25387d 100644 --- a/distributed_cli/src/lib.rs +++ b/distributed_cli/src/lib.rs @@ -1,40 +1,49 @@ -//! The `dctl` CLI for Distributed services — both a binary and a library. +//! The `distributed` CLI for Distributed applications — both a binary and a library. //! -//! It bundles two things in one crate so there is no cross-repo coordination: -//! -//! - **Pure generation** (the [`generate`]/[`atlas`] modules): the rules for a -//! Distributed service project — Cargo layout, Rust source templates, manifest -//! wiring, GitOps/Knative inference, GitHub workflows, and Atlas schema -//! resources. These perform no I/O — [`generate_service_scaffold`] takes a -//! [`ServiceScaffoldSpec`] and returns a [`GeneratedProject`]; -//! [`render_atlas_schema`] wraps desired-state SQL into an `AtlasSchema`. -//! - **The command surface** (the [`cli`] module): the clap types and [`run`] -//! dispatcher that own the filesystem / process side effects (writing files, -//! running `gh`, compiling the manifest harness). -//! -//! The `dctl` binary parses [`ServiceArgs`] and calls [`run`]. Another CLI (e.g. -//! `hops`) can depend on this crate, mount [`ServiceArgs`] under its own -//! subcommand, and dispatch with [`run`] — re-exporting the commands rather than -//! reimplementing them. +//! It bundles pure generation (`generate` / `atlas`), the contract lifecycle +//! surface (`contracts`), and the clap command surface (`cli`). The standalone +//! binary parses [`DistributedArgs`] and dispatches with [`run_distributed`]. +//! Host CLIs such as `hops` may mount [`ServiceArgs`] and dispatch with [`run`]. mod atlas; mod cli; mod client_compiler; +pub mod contracts; mod generate; mod manifest_harness; mod skills; pub use atlas::{render_atlas_schema, AtlasDatabaseUrl, AtlasSchemaSpec}; pub use cli::{ - run, AgentHarness, Bus, ClientArgs, ClientManifestArgs, DescribeArgs, Framework, GitopsPromote, - ManifestFormat, Metrics, ScaffoldArgs, SchemaArgs, SchemaDialect, SchemaFormat, ServiceArgs, - ServiceCommands, SkillsArgs, SkillsCommands, SkillsInitArgs, Store, Transport, + run, run_distributed, AgentHarness, Bus, ClientArgs, ClientManifestArgs, ContractsAcceptArgs, + ContractsArgs, ContractsCheckArgs, ContractsCommands, ContractsOutput, DescribeArgs, + DistributedArgs, DistributedCommands, Framework, GitopsPromote, ManifestFormat, Metrics, + ScaffoldArgs, SchemaArgs, SchemaDialect, SchemaFormat, ServiceArgs, ServiceCommands, + SkillsArgs, SkillsCommands, SkillsInitArgs, Store, Transport, }; pub use client_compiler::{ compile_client, ClientCompileError, ClientCompileInput, ClientDocument, ClientRouteDiscovery, ClientRouteRegistration, ClientSourceLocation, ClientSurfaceSelector, GeneratedClientFile, GeneratedClientProject, GeneratedOperationSummary, GeneratedRoutePlan, }; +pub use contracts::{ + check_migration_history, check_migration_inventory, check_predecessor_chain, + classify_release_programs, classify_snapshot_diff, close_local_contract_chain, contracts_accept, + contracts_check, diff_snapshots, snapshot_from_json, ArtifactIdentity, ArtifactPredecessor, + ArtifactProvenance, BaselineAvailability, ClassifiedChange, ClientDeclaration, ClientInventory, + ClientProgramArtifact, ClientProgramAsset, ClientProgramDescriptor, ClientProgramSurface, + ContractAcceptScope, ContractArtifactKind, ContractCatalog, ContractCheckResult, + ContractDiagnostic, ContractDiagnosticCode, ContractEntry, ContractError, ContractScope, + ContractsAcceptReport, ContractsCheckReport, EnvironmentPolicyReference, LifecycleDecision, + MigrationDialect, MigrationEntry, MigrationFile, MigrationHistoryCheck, MigrationInventory, + ObservedPredecessor, ProgramCompatibility, SafeDiagnosticValue, + SemanticSnapshot, SnapshotChange, SnapshotDiff, SnapshotEntry, MAX_CATALOG_BYTES, + MAX_CATALOG_DIRECTORIES, MAX_CATALOG_DIRECTORY_DEPTH, MAX_CATALOG_DIRECTORY_ENTRIES, + MAX_CATALOG_ENTRIES, MAX_CATALOG_FILES, MAX_CATALOG_GLOB_MATCHES, MAX_CATALOG_JSON_DEPTH, + MAX_MIGRATIONS, MAX_MIGRATION_INVENTORY_BYTES, MAX_MIGRATION_SQL_BYTES, MAX_SNAPSHOT_DEPTH, + MAX_SNAPSHOT_PATHS, MAX_SNAPSHOT_VALUE_BYTES, MIGRATION_INVENTORY_PATH, + MIGRATION_INVENTORY_SCHEMA_VERSION, MIGRATION_OWNER, MIGRATION_SCOPE, +}; pub use generate::{generate_service_scaffold, package_name}; pub use skills::{embedded_skills, generate_skills, EmbeddedFile, EmbeddedSkill, SkillsInitSpec}; diff --git a/distributed_cli/src/main.rs b/distributed_cli/src/main.rs index d3ed4e23..a8099b5d 100644 --- a/distributed_cli/src/main.rs +++ b/distributed_cli/src/main.rs @@ -1,18 +1,18 @@ use clap::Parser; -use distributed_cli::ServiceArgs; +use distributed_cli::DistributedArgs; -/// The `dctl` CLI: scaffold Distributed services, compile typed client -/// artifacts, describe manifests, and render schema artifacts. +/// The `distributed` CLI: contracts lifecycle, scaffold, client compile, +/// describe manifests, and render schema artifacts. #[derive(Parser, Debug)] -#[command(name = "dctl", version, about, long_about = None)] +#[command(name = "distributed", version, about, long_about = None)] struct Cli { #[command(flatten)] - args: ServiceArgs, + args: DistributedArgs, } fn main() { let cli = Cli::parse(); - if let Err(err) = distributed_cli::run(&cli.args) { + if let Err(err) = distributed_cli::run_distributed(&cli.args) { eprintln!("error: {err}"); std::process::exit(1); } diff --git a/distributed_cli/src/manifest_harness.rs b/distributed_cli/src/manifest_harness.rs index 235e5683..b577bb92 100644 --- a/distributed_cli/src/manifest_harness.rs +++ b/distributed_cli/src/manifest_harness.rs @@ -1,4 +1,4 @@ -//! The manifest harness: `describe`/`schema`/`client-manifest` compile a tiny +//! The artifact harness: `describe`/`schema`/`client-manifest` compile a tiny //! generated crate that depends on the target service and calls its portable //! export entrypoint. This module owns that codegen and the nested `cargo` //! invocations; the `cli` module maps flags onto @@ -46,9 +46,8 @@ impl HarnessMode { fn default_entrypoint(self) -> &'static str { match self { HarnessMode::ClientManifest => "distributed_client_surface", - HarnessMode::DescribeJson | HarnessMode::SchemaSql(_) | HarnessMode::SchemaGraphql => { - "distributed_manifest" - } + HarnessMode::DescribeJson => "application_manifest", + HarnessMode::SchemaSql(_) | HarnessMode::SchemaGraphql => "read_model_catalog", } } } @@ -76,14 +75,14 @@ pub(crate) fn run_manifest_harness( // resolves its inherited dependencies against the generated harness. let harness_root = package .target_directory - .join("dctl-manifest-harness") + .join("distributed-manifest-harness") .join(&package.name); let harness_dir = harness_root.join(mode.cache_key()); fs::create_dir_all(harness_dir.join("src"))?; fs::write( harness_dir.join("Cargo.toml"), harness_cargo_toml( - &format!("dctl-manifest-harness-{}", mode.cache_key()), + &format!("distributed-manifest-harness-{}", mode.cache_key()), &crate_ident, &package.name, &package.directory, @@ -161,8 +160,7 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { HarnessMode::DescribeJson => format!( r#"fn main() {{ let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - println!("{{}}", serde_json::to_string_pretty(&envelope).expect("manifest should serialize")); + println!("{{}}", serde_json::to_string_pretty(&manifest).expect("manifest should serialize")); }} "# ), @@ -173,12 +171,10 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { }; format!( r#"fn main() {{ - let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - let statements = envelope - .project + let catalog = {entrypoint}(); + let statements = catalog .sql_statements(distributed::table::TableSqlDialect::{dialect}) - .expect("manifest SQL should render"); + .expect("read-model SQL should render"); if !statements.is_empty() {{ println!("{{}}", statements.join("\n\n")); }} @@ -188,12 +184,9 @@ fn harness_main_rs(entrypoint: &str, mode: HarnessMode) -> String { } HarnessMode::SchemaGraphql => format!( r#"fn main() {{ - let manifest = {entrypoint}(); - let envelope = distributed::DistributedManifestEnvelope::new(manifest); - let sdl = envelope - .project - .graphql_sdl() - .expect("manifest GraphQL SDL should render"); + let catalog = {entrypoint}(); + let sdl = distributed::graphql::graphql_sdl_for_tables(&catalog.tables) + .expect("read-model GraphQL SDL should render"); print!("{{}}", sdl); }} "# @@ -347,7 +340,7 @@ mod tests { #[test] fn harness_is_standalone_inside_cached_target_directory() { let cargo_toml = harness_cargo_toml( - "dctl-manifest-harness-schema-postgres", + "distributed-manifest-harness-schema-postgres", "todo_model", "todo-model", Path::new("/tmp/todo-model"), @@ -357,13 +350,13 @@ mod tests { ); assert!(cargo_toml.contains("\n[workspace]\n")); - assert!(cargo_toml.contains("name = \"dctl-manifest-harness-schema-postgres\"")); + assert!(cargo_toml.contains("name = \"distributed-manifest-harness-schema-postgres\"")); } #[test] fn schema_harness_uses_public_table_module_sql_dialect() { let main_rs = harness_main_rs( - "orders_service::distributed_manifest", + "orders_service::read_model_catalog", HarnessMode::SchemaSql(SchemaDialect::Postgres), ); diff --git a/distributed_cli/src/skills.rs b/distributed_cli/src/skills.rs index 9f5d42e6..5930c5d9 100644 --- a/distributed_cli/src/skills.rs +++ b/distributed_cli/src/skills.rs @@ -43,7 +43,7 @@ pub struct EmbeddedSkill { static SKILLS: [EmbeddedSkill; 4] = [ EmbeddedSkill { name: "distributed-usage", - description: "Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and dctl generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model.", + description: "Build Distributed CQRS/event-sourced Rust services where you mostly write models and handlers while the framework and distributed generate persistence, transports, manifests, and deploy wiring. Use model-first TDD to specify plain aggregate behavior with fast unit tests before implementing models and thin handlers. Use when designing, testing, writing, or modifying a Distributed service or domain model.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-usage/SKILL.md"), @@ -51,7 +51,7 @@ static SKILLS: [EmbeddedSkill; 4] = [ }, EmbeddedSkill { name: "distributed-ci", - description: "Set up CI, release workflows, and GitOps promotion for a Distributed service with dctl scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation.", + description: "Set up CI, release workflows, and GitOps promotion for a Distributed service with distributed scaffold flags (--github, --gitops, --gitops-promote). Use when configuring pipelines, previews, releases, or deploy automation.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-ci/SKILL.md"), @@ -59,7 +59,7 @@ static SKILLS: [EmbeddedSkill; 4] = [ }, EmbeddedSkill { name: "distributed-schema", - description: "Inspect a Distributed service manifest and render schema artifacts - dctl describe (manifest JSON), dctl schema (migration SQL or an Atlas Operator resource), and the distributed_manifest() envelope contract. Use when working on read-model schemas, migrations, or schema automation.", + description: "Inspect a Distributed read-model catalog and render schema artifacts - distributed describe (application JSON), distributed schema (migration SQL or an Atlas Operator resource), and the explicit application/read-model artifact contract. Use when working on read-model schemas, migrations, or schema automation.", files: &[EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../skills/distributed-schema/SKILL.md"), @@ -87,7 +87,7 @@ pub fn embedded_skills() -> &'static [EmbeddedSkill] { pub const AGENTS_MD_FILE: &str = "AGENTS.md"; const AGENTS_MD_BEGIN: &str = - ""; + ""; const AGENTS_MD_END: &str = ""; /// What to generate. The pure input to [`generate_skills`]. All paths in the diff --git a/distributed_cli/tests/cli_client.rs b/distributed_cli/tests/cli_client.rs index 4c3d4bbf..f3f3dc98 100644 --- a/distributed_cli/tests/cli_client.rs +++ b/distributed_cli/tests/cli_client.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl client`: drive the real binary against a small +//! Integration tests for `distributed client`: drive the real binary against a small //! manifest-v2 project and verify generation, read-only drift checking, //! authorization-surface selection, document discovery, and explicit `@load` //! route registration. @@ -312,12 +312,12 @@ fn write_document(project: &Path, relative: &str, source: &str) { } fn dctl_client(project: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .arg("client") .args(args) .current_dir(project) .output() - .expect("dctl should run") + .expect("distributed should run") } fn generate(project: &Path, documents: &str, extra: &[&str]) -> Output { @@ -743,7 +743,7 @@ fn generation_rejects_an_unproven_old_surface_chunk_without_provenance() { fs::create_dir_all(generated.join("operations")).expect("create old output tree"); fs::write( generated.join("operations/admin-only.ts"), - "/** GENERATED by dctl client. Do not edit. */\nexport const secret = true;\n", + "/** GENERATED by distributed client. Do not edit. */\nexport const secret = true;\n", ) .expect("write unproven elevated chunk"); let before = snapshot_tree(&generated); diff --git a/distributed_cli/tests/cli_manifest.rs b/distributed_cli/tests/cli_manifest.rs index 90e6bf41..11d1d731 100644 --- a/distributed_cli/tests/cli_manifest.rs +++ b/distributed_cli/tests/cli_manifest.rs @@ -17,20 +17,20 @@ fn fixture_manifest() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/orders-service/Cargo.toml") } -/// Run `dctl ` against the fixture, returning stdout. Always passes +/// Run `distributed ` against the fixture, returning stdout. Always passes /// `--manifest-path` and `--distributed-path` so resolution is deterministic. -fn dctl(args: &[&str]) -> String { +fn distributed(args: &[&str]) -> String { let root = distributed_root(); let manifest = fixture_manifest(); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args(args) .args(["--manifest-path", manifest.to_str().unwrap()]) .args(["--distributed-path", root.to_str().unwrap()]) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!( output.status.success(), - "dctl {args:?} failed:\n{}", + "distributed {args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).into_owned() @@ -39,7 +39,7 @@ fn dctl(args: &[&str]) -> String { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn describe_emits_manifest_json() { - let json = dctl(&["describe"]); + let json = distributed(&["describe"]); assert!(json.contains("\"schema_version\""), "json: {json}"); assert!(json.contains("\"orders\""), "json: {json}"); } @@ -47,7 +47,7 @@ fn describe_emits_manifest_json() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn client_manifest_uses_service_surface_export() { - let json = dctl(&["client-manifest"]); + let json = distributed(&["client-manifest"]); let manifest: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(manifest["manifest_version"], 2); assert_eq!(manifest["protocol_version"], 1); @@ -104,7 +104,7 @@ fn client_manifest_uses_service_surface_export() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn emitted_client_manifest_is_accepted_by_client_compiler() { - let json = dctl(&["client-manifest"]); + let json = distributed(&["client-manifest"]); let manifest: serde_json::Value = serde_json::from_str(&json).unwrap(); let project = distributed_cli::compile_client(distributed_cli::ClientCompileInput::new( manifest, @@ -124,7 +124,7 @@ fn emitted_client_manifest_is_accepted_by_client_compiler() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_postgres_sql() { - let sql = dctl(&["schema", "--dialect", "postgres"]); + let sql = distributed(&["schema", "--dialect", "postgres"]); assert!(sql.contains("CREATE TABLE"), "sql: {sql}"); assert!(sql.contains("orders"), "sql: {sql}"); } @@ -132,7 +132,7 @@ fn schema_renders_postgres_sql() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_sqlite_sql() { - let sql = dctl(&["schema", "--dialect", "sqlite"]); + let sql = distributed(&["schema", "--dialect", "sqlite"]); assert!(sql.contains("CREATE TABLE"), "sql: {sql}"); assert!(sql.contains("orders"), "sql: {sql}"); // SQLite renders upper-case storage classes; postgres uses lower-case @@ -143,7 +143,7 @@ fn schema_renders_sqlite_sql() { #[test] #[ignore = "compiles the fixture via the manifest harness; run in the integration job"] fn schema_renders_atlas_resource() { - let yaml = dctl(&[ + let yaml = distributed(&[ "schema", "--format", "atlas", diff --git a/distributed_cli/tests/cli_scaffold.rs b/distributed_cli/tests/cli_scaffold.rs index e453d323..499a7722 100644 --- a/distributed_cli/tests/cli_scaffold.rs +++ b/distributed_cli/tests/cli_scaffold.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl scaffold`: drive the real binary and assert the +//! Integration tests for `distributed scaffold`: drive the real binary and assert the //! generated project tree. Pure generation + filesystem, so these are fast and //! need no nested compilation — `cli_scaffold_compile.rs` owns the (ignored) //! end-to-end compile checks. @@ -15,7 +15,7 @@ fn distributed_root() -> PathBuf { .to_path_buf() } -/// Run `dctl scaffold orders --path / ` against a +/// Run `distributed scaffold orders --path / ` against a /// fresh directory, returning the raw process output and the output directory. fn run_scaffold(dir_name: &str, extra_args: &[&str]) -> (Output, PathBuf) { let out_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(dir_name); @@ -24,10 +24,10 @@ fn run_scaffold(dir_name: &str, extra_args: &[&str]) -> (Output, PathBuf) { (output, out_dir) } -/// Run `dctl scaffold orders --path ` without touching +/// Run `distributed scaffold orders --path ` without touching /// the directory first (error-path tests pre-populate it). fn scaffold_into(out_dir: &Path, extra_args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .args([ "scaffold", "orders", @@ -38,7 +38,7 @@ fn scaffold_into(out_dir: &Path, extra_args: &[&str]) -> Output { ]) .args(extra_args) .output() - .expect("dctl should run") + .expect("distributed should run") } /// Scaffold and assert success, returning the output directory. @@ -46,7 +46,7 @@ fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { let (output, out_dir) = run_scaffold(dir_name, extra_args); assert!( output.status.success(), - "dctl scaffold {extra_args:?} failed:\n{}", + "distributed scaffold {extra_args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); out_dir @@ -242,10 +242,10 @@ fn scaffold_refuses_a_non_empty_directory() { #[test] fn describe_reports_a_missing_manifest() { let missing = Path::new(env!("CARGO_TARGET_TMPDIR")).join("no-such-dir/Cargo.toml"); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args(["describe", "--manifest-path", missing.to_str().unwrap()]) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!(!output.status.success(), "describe should fail"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/distributed_cli/tests/cli_scaffold_compile.rs b/distributed_cli/tests/cli_scaffold_compile.rs index f5088d52..9811ed7a 100644 --- a/distributed_cli/tests/cli_scaffold_compile.rs +++ b/distributed_cli/tests/cli_scaffold_compile.rs @@ -1,4 +1,4 @@ -//! End-to-end compile tests for `dctl scaffold`: scaffold a project, point its +//! End-to-end compile tests for `distributed scaffold`: scaffold a project, point its //! `distributed` dependency at this workspace, and `cargo check` the output. //! //! The fast generation tests only assert rendered text, so template drift @@ -23,7 +23,7 @@ fn distributed_root() -> PathBuf { fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { let out_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(dir_name); let _ = fs::remove_dir_all(&out_dir); - let output = Command::new(env!("CARGO_BIN_EXE_dctl")) + let output = Command::new(env!("CARGO_BIN_EXE_distributed")) .args([ "scaffold", "orders", @@ -34,10 +34,10 @@ fn scaffold(dir_name: &str, extra_args: &[&str]) -> PathBuf { ]) .args(extra_args) .output() - .expect("dctl should run"); + .expect("distributed should run"); assert!( output.status.success(), - "dctl scaffold {extra_args:?} failed:\n{}", + "distributed scaffold {extra_args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); out_dir diff --git a/distributed_cli/tests/cli_skills_init.rs b/distributed_cli/tests/cli_skills_init.rs index 8c7305d0..32b4d94b 100644 --- a/distributed_cli/tests/cli_skills_init.rs +++ b/distributed_cli/tests/cli_skills_init.rs @@ -1,4 +1,4 @@ -//! Integration tests for `dctl skills init` / `dctl skills list`: drive the +//! Integration tests for `distributed skills init` / `distributed skills list`: drive the //! real binary against temp directories and assert the extracted skill tree, //! harness wiring, idempotent re-runs, and drift semantics. No network, no //! repo checkout — the skills are embedded in the binary. @@ -9,7 +9,7 @@ use std::process::{Command, Output}; const SKILL_NAMES: [&str; 3] = ["distributed-usage", "distributed-ci", "distributed-schema"]; const BEGIN: &str = - ""; + ""; const END: &str = ""; /// A fresh project directory under the target tmpdir. @@ -20,21 +20,21 @@ fn project_dir(name: &str) -> PathBuf { dir } -/// Run `dctl skills ` with the given project directory as cwd. +/// Run `distributed skills ` with the given project directory as cwd. fn dctl_skills(cwd: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dctl")) + Command::new(env!("CARGO_BIN_EXE_distributed")) .arg("skills") .args(args) .current_dir(cwd) .output() - .expect("dctl should run") + .expect("distributed should run") } fn init_ok(cwd: &Path, args: &[&str]) -> (String, String) { let output = dctl_skills(cwd, args); assert!( output.status.success(), - "dctl skills {args:?} failed:\n{}", + "distributed skills {args:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); ( diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json new file mode 100644 index 00000000..6cf45735 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-chain-cycle.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "entries": { + "application": { + "id": "application", + "kind": "application_manifest", + "scope": { "id": "cycle/application" }, + "owner": "cycle/application", + "identity": { "kind": "application_manifest", "value": "sha256:application" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.application" + }, + "predecessor": { + "entry_id": "deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" } + }, + "outputs": { "application": "distributed_cli/src/lib.rs" } + }, + "deployment": { + "id": "deployment", + "kind": "deployment_plan", + "scope": { "id": "cycle/deployment" }, + "owner": "cycle/deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.deployment" + }, + "predecessor": { + "entry_id": "application", + "identity": { "kind": "application_manifest", "value": "sha256:application" } + }, + "outputs": { "deployment": "distributed_cli/src/cli.rs" } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json new file mode 100644 index 00000000..86fa3648 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-duplicate-scope.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "entries": [ + { + "id": "first", + "kind": "application_manifest", + "scope": { "id": "duplicate-scope" }, + "owner": "owner/first", + "identity": { "kind": "application_manifest", "value": "sha256:first" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.first" + }, + "outputs": { "first": "distributed_cli/Cargo.toml" } + }, + { + "id": "second", + "kind": "deployment_plan", + "scope": { "id": "duplicate-scope" }, + "owner": "owner/second", + "identity": { "kind": "deployment_plan", "value": "sha256:second" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.second" + }, + "outputs": { "second": "distributed_cli/src/lib.rs" } + } + ] +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json new file mode 100644 index 00000000..61f10b3f --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-environment-value.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "entries": { + "environment-value": { + "id": "environment-value", + "kind": "resolved_deployment", + "scope": { "id": "environment/value" }, + "owner": "dpl/environment/value", + "identity": { "kind": "resolved_deployment", "value": "sha256:environment-value" }, + "provenance": { + "sources": ["distributed_cli/Cargo.toml"], + "generator": "test.environment" + }, + "outputs": { "deployment": "distributed_cli/src/lib.rs" }, + "environment": { + "DATABASE_URL": "postgres://user:password@example.invalid/db" + } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json new file mode 100644 index 00000000..4176d5fa --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-escaping-path.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "entries": { + "escaping": { + "id": "escaping", + "kind": "migration_inventory", + "scope": { "id": "repository/escaping" }, + "owner": "migrations/escaping", + "identity": { "kind": "migration_inventory", "value": "sha256:escaping" }, + "provenance": { + "sources": ["../outside.json"], + "generator": "test.escaping" + }, + "outputs": { "output": "distributed_cli/Cargo.toml" } + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json b/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json new file mode 100644 index 00000000..8096b70f --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/catalog/catalog-valid.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "entries": { + "application-manifest": { + "id": "application-manifest", + "kind": "application_manifest", + "scope": { "id": "application/e2e-ui" }, + "owner": "cmp/e2e-ui/application", + "identity": { "kind": "application_manifest", "value": "sha256:application" }, + "provenance": { + "sources": ["tests/e2e-ui/crates/service/src/lib.rs"], + "generator": "cmp.application_manifest", + "source_revision": "1b0011c00c7ea426139e3dcd38b57ea34d031b57" + }, + "outputs": { + "application": "tests/e2e-ui/crates/service/Cargo.toml" + }, + "lifecycle": ["check"] + }, + "deployment-plan": { + "id": "deployment-plan", + "kind": "deployment_plan", + "scope": { "id": "deployment/e2e-ui" }, + "owner": "cmp/e2e-ui/deployment", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" }, + "provenance": { + "sources": ["tests/e2e-ui/crates/service/src/service.rs"], + "generator": "cmp.deployment_plan" + }, + "predecessor": { + "entry_id": "application-manifest", + "identity": { "kind": "application_manifest", "value": "sha256:application" } + }, + "outputs": { + "deployment": "tests/e2e-ui/crates/runner/Cargo.toml" + }, + "lifecycle": ["check"] + }, + "client-program": { + "id": "client-program", + "kind": "client_program_descriptor", + "scope": { "id": "client/e2e-ui" }, + "owner": "query-layer/e2e-ui/program", + "identity": { "kind": "client_program_descriptor", "value": "sha256:client-program" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "query-layer.client_program" + }, + "predecessor": { + "entry_id": "deployment-plan", + "identity": { "kind": "deployment_plan", "value": "sha256:deployment" } + }, + "outputs": { + "client-program": "tests/e2e-ui/ui/src/lib/generated/public/manifest.json" + }, + "lifecycle": ["check"] + }, + "resolved-deployment": { + "id": "resolved-deployment", + "kind": "resolved_deployment", + "scope": { "id": "resolved/e2e-ui" }, + "owner": "dpl/e2e-ui/resolution", + "identity": { "kind": "resolved_deployment", "value": "sha256:resolved" }, + "provenance": { + "sources": ["tests/e2e-ui/ui/distributed.clients.json"], + "generator": "dpl.resolved_deployment" + }, + "predecessor": { + "entry_id": "client-program", + "identity": { "kind": "client_program_descriptor", "value": "sha256:client-program" } + }, + "outputs": { + "resolved": "tests/e2e-ui/ui/src/lib/generated/admin/manifest.json" + }, + "environment_policy": { + "identity": "sha256:environment-policy", + "name": "local", + "reference": "dpl/environment/local" + }, + "lifecycle": ["check", "render"] + } + } +} diff --git a/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json b/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json new file mode 100644 index 00000000..59848da0 --- /dev/null +++ b/distributed_cli/tests/fixtures/contracts/client-inventory-parity.json @@ -0,0 +1,150 @@ +{ + "vectors": [ + { + "name": "null optional entrypoint is valid", + "valid": true, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example", + "manifest_entrypoint": null + } + ] + } + }, + { + "name": "unknown top-level field is invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ], + "extra": true + } + }, + { + "name": "module segments must begin with an alphanumeric", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/.client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "module segments cannot contain dot-dot", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client..nested", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "dot-dot identifiers are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e..ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "secret-like paths are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/token=secret.graphql"], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "windows absolute output paths are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "C:/generated/example" + } + ] + } + }, + { + "name": "duplicate documents are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": [ + "src/routes/example/+page.graphql", + "src/routes/example/+page.graphql" + ], + "output": "src/lib/generated/example" + } + ] + } + }, + { + "name": "duplicate modules are invalid", + "valid": false, + "inventory": { + "schema_version": 1, + "clients": [ + { + "module": "$distributed/client", + "surface": "e2e-ui", + "documents": ["src/routes/example/+page.graphql"], + "output": "src/lib/generated/example" + }, + { + "module": "$distributed/client", + "surface": "e2e-admin", + "documents": ["src/routes/admin/+page.graphql"], + "output": "src/lib/generated/admin" + } + ] + } + } + ] +} diff --git a/distributed_cli/tests/fixtures/generated-commands.ts b/distributed_cli/tests/fixtures/generated-commands.ts index 8b4079f0..6994f872 100644 --- a/distributed_cli/tests/fixtures/generated-commands.ts +++ b/distributed_cli/tests/fixtures/generated-commands.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import { createReplicaCommandRuntime, diff --git a/distributed_cli/tests/fixtures/generated-operation.ts b/distributed_cli/tests/fixtures/generated-operation.ts index 58891f08..bba1893d 100644 --- a/distributed_cli/tests/fixtures/generated-operation.ts +++ b/distributed_cli/tests/fixtures/generated-operation.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import type { ReplicaOperationArtifact, ReplicaValue } from '@hops-ops/distributed/replica'; type Operation_ScalarInputs_Input_todo_bool_exp = { diff --git a/distributed_cli/tests/fixtures/generated-scalar-operation.ts b/distributed_cli/tests/fixtures/generated-scalar-operation.ts index 9a6e0af3..8666e7cc 100644 --- a/distributed_cli/tests/fixtures/generated-scalar-operation.ts +++ b/distributed_cli/tests/fixtures/generated-scalar-operation.ts @@ -1,4 +1,4 @@ -/** GENERATED by dctl client. Do not edit. */ +/** GENERATED by distributed client. Do not edit. */ import type { ReplicaOperationArtifact } from '@hops-ops/distributed/replica'; export type Operation_RustRuntimeBridge_Variables = { diff --git a/distributed_cli/tests/fixtures/orders-service/Cargo.toml b/distributed_cli/tests/fixtures/orders-service/Cargo.toml index 8e41be49..763f189a 100644 --- a/distributed_cli/tests/fixtures/orders-service/Cargo.toml +++ b/distributed_cli/tests/fixtures/orders-service/Cargo.toml @@ -1,4 +1,4 @@ -# Standalone fixture compiled by the `dctl` manifest harness in integration tests. +# Standalone fixture compiled by the `distributed` manifest harness in integration tests. # Its own `[workspace]` decouples it from the repo workspace. [package] name = "orders-service" diff --git a/distributed_cli/tests/fixtures/orders-service/src/lib.rs b/distributed_cli/tests/fixtures/orders-service/src/lib.rs index 213a48ed..8e81ee8e 100644 --- a/distributed_cli/tests/fixtures/orders-service/src/lib.rs +++ b/distributed_cli/tests/fixtures/orders-service/src/lib.rs @@ -1,5 +1,5 @@ -//! Minimal Distributed service fixture for `dctl` manifest-harness integration -//! tests: one read model (→ an `orders` table) registered in the project manifest. +//! Minimal Distributed service fixture for `distributed` manifest-harness integration +//! tests: one read model (→ an `orders` table) registered in the read-model catalog. use std::any::TypeId; @@ -10,8 +10,8 @@ use distributed::graphql::{ }; use distributed::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; use distributed::{ - Aggregate, AggregateRepository, DistributedProjectManifest, Entity, EventRecord, - InMemoryRepository, ReadModel, + Aggregate, AggregateRepository, Application, ApplicationManifest, Entity, EventRecord, + InMemoryRepository, ReadModel, ReadModelCatalog, SurfaceSpec, }; use serde::{Deserialize, Serialize}; @@ -102,16 +102,34 @@ async fn project_order( )) } -/// The entrypoint `dctl describe`/`dctl schema` call by default -/// (`::distributed_manifest`). -pub fn distributed_manifest() -> DistributedProjectManifest { - DistributedProjectManifest::new("orders").read_model::() +/// The entrypoint `distributed schema` calls by default +/// (`::read_model_catalog`). This is the physical read-model catalog, +/// not the logical application manifest. +pub fn read_model_catalog() -> ReadModelCatalog { + ReadModelCatalog::new("orders").read_model::() } -/// Pool-free client export used by `dctl client-manifest`. Both the CLI harness +/// The logical application artifact is built from the same non-executable +/// Surface contract as the client export; physical catalog SQL remains a +/// separate read-model utility. +pub fn application_manifest() -> ApplicationManifest { + let catalog = read_model_catalog(); + let surface = build_surface(&catalog.tables, &SurfaceOptions::sqlite()) + .expect("fixture Surface should build"); + let surface = SurfaceSpec::from_surface("orders", &surface) + .expect("fixture Surface contract should compile"); + Application::new("orders") + .surface(surface) + .build() + .expect("fixture application manifest should compile") + .manifest() + .clone() +} + +/// Pool-free client export used by `distributed client-manifest`. Both the CLI harness /// and a runtime engine finish through `DistributedClientSurfaceExport::manifest`. pub fn distributed_client_surface() -> DistributedClientSurfaceExport { - let project = distributed_manifest(); + let catalog = read_model_catalog(); let service = Service::new().named("orders").routes( Routes::new() .with_repo(AggregateRepository::<_, FixtureAggregate>::new( @@ -124,7 +142,7 @@ pub fn distributed_client_surface() -> DistributedClientSurfaceExport { ) .handle(project_order), ); - let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) + let full = build_surface(&catalog.tables, &SurfaceOptions::sqlite()) .expect("fixture Surface should build") .with_service(&service) .expect("fixture typed service should bind") @@ -139,6 +157,6 @@ pub fn distributed_client_surface() -> DistributedClientSurfaceExport { )]); let user = surface_for_role(&full, "user", &grants).expect("fixture role policy should be valid"); - DistributedClientSurfaceExport::from_project(&project, user) + DistributedClientSurfaceExport::from_selected("orders", user) .expect("fixture Surface should be role-selected") } diff --git a/distributed_macros/Cargo.toml b/distributed_macros/Cargo.toml index 81fd26f8..91b01e07 100644 --- a/distributed_macros/Cargo.toml +++ b/distributed_macros/Cargo.toml @@ -9,7 +9,12 @@ repository.workspace = true [lib] proc-macro = true +[features] +application-runtime = ["distributed/application-runtime"] +runtime = ["application-runtime"] + [dependencies] +proc-macro-crate = "3.5" proc-macro2 = "1.0" quote = "1.0" sha2 = "0.10" diff --git a/distributed_macros/src/application.rs b/distributed_macros/src/application.rs new file mode 100644 index 00000000..1004e437 --- /dev/null +++ b/distributed_macros/src/application.rs @@ -0,0 +1,123 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, Ident, LitStr, Token, Type, Visibility}; + +struct ApplicationInput { + visibility: Visibility, + name: Ident, + id: LitStr, + modules: Vec, + surfaces: Vec, + capabilities: Vec, + extensions: Vec, +} + +impl Parse for ApplicationInput { + fn parse(input: ParseStream<'_>) -> syn::Result { + let visibility: Visibility = input.parse()?; + let name: Ident = input.parse()?; + if input.peek(Token![:]) { + input.parse::()?; + let _: Type = input.parse()?; + } + if input.peek(Token![=]) { + input.parse::()?; + } + let content; + syn::braced!(content in input); + let mut output = Self { + visibility, + name, + id: LitStr::new("", proc_macro2::Span::call_site()), + modules: Vec::new(), + surfaces: Vec::new(), + capabilities: Vec::new(), + extensions: Vec::new(), + }; + while !content.is_empty() { + let field: Ident = content.parse()?; + content.parse::()?; + match field.to_string().as_str() { + "id" => output.id = content.parse()?, + "modules" => output.modules = parse_expr_array(&content)?, + "surfaces" => output.surfaces = parse_expr_array(&content)?, + "capabilities" | "required_capabilities" => { + output.capabilities = parse_expr_array(&content)? + } + "extensions" => output.extensions = parse_expr_array(&content)?, + other => { + return Err(syn::Error::new( + field.span(), + format!("unknown application field `{other}`"), + )) + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + if output.id.value().is_empty() { + output.id = LitStr::new( + &output.name.to_string().to_ascii_lowercase(), + output.name.span(), + ); + } + Ok(output) + } +} + +fn parse_expr_array(input: ParseStream<'_>) -> syn::Result> { + let content; + syn::bracketed!(content in input); + Ok(Punctuated::::parse_terminated(&content)? + .into_iter() + .collect()) +} + +pub fn expand(input: proc_macro2::TokenStream) -> syn::Result { + let framework = crate::shared::framework_path()?; + let input = syn::parse2::(input)?; + let ApplicationInput { + visibility, + name, + id, + modules, + surfaces, + capabilities, + extensions, + } = input; + let accessor = format_ident!("{}", name.to_string().to_lowercase()); + let modules = modules.iter().map(|value| quote! { (&*#value).clone() }); + let surfaces = surfaces.iter().map(|value| quote! { (&*#value).clone() }); + let capabilities = capabilities + .iter() + .map(|value| quote! { #value }) + .collect::>(); + let extensions = extensions + .iter() + .map(|value| quote! { (&*#value).clone() }) + .collect::>(); + let capability_builder = if capabilities.is_empty() { + quote! {} + } else { + quote! { .required_capabilities([#(#capabilities),*]) } + }; + Ok(quote! { + #visibility static #name: ::std::sync::LazyLock<#framework::application::Application> = + ::std::sync::LazyLock::new(|| { + #framework::application::Application::new(#id) + .modules([#(#modules),*]) + .surfaces([#(#surfaces),*]) + .extensions([#(#extensions),*]) + #capability_builder + .build() + .unwrap_or_else(|error| panic!("invalid generated application: {error}")) + }); + + #visibility fn #accessor() -> &'static #framework::application::Application { + &#name + } + }) +} diff --git a/distributed_macros/src/command.rs b/distributed_macros/src/command.rs new file mode 100644 index 00000000..51100206 --- /dev/null +++ b/distributed_macros/src/command.rs @@ -0,0 +1,510 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, FnArg, Ident, ItemFn, LitStr, PathArguments, ReturnType, Token, Type}; + +#[derive(Default)] +struct CommandArgs { + id: Option, + field_name: Option, + input: Option, + outcome: Option, + roles: Vec, + emits: Vec, + applies: Option, + defaults: Option, + generated_defaults: Vec<(Ident, Ident)>, +} + +impl Parse for CommandArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + match key.to_string().as_str() { + "roles" => { + let content; + syn::parenthesized!(content in input); + let values = Punctuated::::parse_terminated(&content)?; + for value in values { + match value { + syn::Expr::Path(path) if path.path.segments.len() == 1 => { + args.roles.push(LitStr::new( + &path.path.segments[0].ident.to_string(), + path.path.segments[0].ident.span(), + )); + } + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(value), + .. + }) => args.roles.push(value), + other => { + return Err(syn::Error::new_spanned( + other, + "command roles must be identifiers or string literals", + )) + } + } + } + } + "emits" => { + let content; + syn::parenthesized!(content in input); + args.emits = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + } + "applies" => { + let content; + syn::parenthesized!(content in input); + args.applies = Some(content.parse()?); + } + "defaults" | "input_defaults" => { + input.parse::()?; + args.defaults = Some(input.parse()?); + } + "default" => { + let content; + syn::parenthesized!(content in input); + let target: Ident = content.parse()?; + content.parse::()?; + let generator: Ident = content.parse()?; + if !content.is_empty() { + let arguments; + syn::parenthesized!(arguments in content); + if !arguments.is_empty() { + return Err( + arguments.error("input-default generators take no arguments") + ); + } + } + if !matches!(generator.to_string().as_str(), "uuid_v7" | "ulid") { + return Err(syn::Error::new( + generator.span(), + "unknown input-default generator; expected uuid_v7() or ulid()", + )); + } + args.generated_defaults.push((target, generator)); + } + "id" | "field" | "field_name" => { + input.parse::()?; + let value: LitStr = input.parse()?; + if key == "id" { + args.id = Some(value); + } else { + args.field_name = Some(value); + } + } + "input" | "outcome" => { + input.parse::()?; + let value: Type = input.parse()?; + if key == "input" { + args.input = Some(value); + } else { + args.outcome = Some(value); + } + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown command option `{other}`"), + )) + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(args) + } +} + +pub fn expand( + attr: proc_macro2::TokenStream, + item: proc_macro2::TokenStream, +) -> syn::Result { + let framework = crate::shared::framework_path()?; + let args = syn::parse2::(attr)?; + let function = syn::parse2::(item)?; + let id = args.id.ok_or_else(|| { + syn::Error::new( + function.sig.ident.span(), + "command declaration requires `id = \"...\"`", + ) + })?; + if !function.sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + &function.sig.fn_token, + "typed command handlers must be async", + )); + } + let typed_args = function + .sig + .inputs + .iter() + .filter_map(|argument| match argument { + FnArg::Typed(argument) => Some(argument), + FnArg::Receiver(_) => None, + }) + .collect::>(); + if function.sig.inputs.iter().any(|argument| matches!(argument, FnArg::Receiver(_))) + || typed_args.len() != 2 + { + return Err(syn::Error::new_spanned( + &function.sig.inputs, + "typed command handler must have exactly `(context: &CausalCommandContext<'_, Aggregate>, input: Input)` parameters", + )); + } + let context_ty = &typed_args[0].ty; + if !is_causal_context_type(context_ty) { + return Err(syn::Error::new_spanned( + &typed_args[0].ty, + "first typed command parameter must be `&CausalCommandContext<'_, Aggregate>`", + )); + } + let aggregate = causal_context_aggregate_type(context_ty).ok_or_else(|| { + syn::Error::new_spanned( + context_ty, + "first typed command parameter must name the aggregate in `CausalCommandContext`", + ) + })?; + let inferred_input = (*typed_args[1].ty).clone(); + let declared_input = args.input.clone(); + let input = args.input.unwrap_or(inferred_input); + let outcome = args + .outcome + .or_else(|| infer_prepared_outcome(&function.sig.output)) + .ok_or_else(|| { + syn::Error::new_spanned( + &function.sig.output, + "command outcome is not inferable; provide `outcome = PreparedCommand`", + ) + })?; + validate_prepared_return(&function.sig.output, &outcome)?; + if let Some(input) = declared_input.as_ref() { + if !same_type(input, &typed_args[1].ty) { + return Err(syn::Error::new_spanned( + &typed_args[1].ty, + "handler input parameter does not match the declared `input = ...` type", + )); + } + } + let field_name = args.field_name.unwrap_or_else(|| { + LitStr::new( + &id.value() + .chars() + .map(|character| { + if matches!(character, '.' | '-') { + '_' + } else { + character + } + }) + .collect::(), + id.span(), + ) + }); + let function_name = &function.sig.ident; + let command_name = format_ident!("{}_command", function_name); + let spec_name = format_ident!("{}_spec", function_name); + let command_static = format_ident!("{}_COMMAND", function_name.to_string().to_uppercase()); + let spec_static = format_ident!("{}_SPEC", function_name.to_string().to_uppercase()); + let mount_name = format_ident!("{}_mount", function_name); + let mount_static = format_ident!("{}_MOUNT", function_name.to_string().to_uppercase()); + let definition_name = format_ident!("{}_definition", function_name); + let definition_static = format_ident!( + "{}_DEFINITION", + function_name.to_string().to_uppercase() + ); + let command_id_static = format_ident!( + "{}_COMMAND_ID", + function_name.to_string().to_uppercase() + ); + let accessor_name = format_ident!("{}_application_command", function_name); + let register_name = format_ident!("{}_register", function_name); + let visibility = &function.vis; + let roles = args.roles; + let emits = args.emits; + let applies = args.applies; + let defaults = args.defaults; + let generated_defaults = args.generated_defaults; + let mut builder = quote! { + #framework::graphql::typed_command::<#input, #outcome>(#id) + .field_name(#field_name) + }; + if !roles.is_empty() { + builder.extend(quote! { .roles([#(#roles),*]) }); + } + if !emits.is_empty() { + builder.extend(quote! { .emits(#framework::events!(#(#emits),*)) }); + } + if let Some(applies) = applies { + builder.extend(quote! { .applies(#applies) }); + } + if let Some(defaults) = defaults { + builder.extend(quote! { .input_defaults(#defaults) }); + } else if !generated_defaults.is_empty() { + let defaults = generated_defaults.iter().map(|(field, generator)| { + quote! { default input.#field = #generator(); } + }); + builder.extend(quote! { + .input_defaults(#framework::command_input_defaults! { + input: #input; + #(#defaults)* + }) + }); + } + Ok(quote! { + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #function + + #visibility fn #command_name() -> #framework::graphql::TypedCommand<#input, #outcome> { + #builder + } + + #visibility static #command_static: ::std::sync::LazyLock< + #framework::graphql::TypedCommand<#input, #outcome> + > = ::std::sync::LazyLock::new(#command_name); + + #visibility fn #spec_name() -> #framework::application::ApplicationResult< + #framework::application::CommandSpec + > { + (#command_static).spec() + } + + #visibility static #spec_static: ::std::sync::LazyLock< + #framework::application::CommandSpec + > = ::std::sync::LazyLock::new(|| + #spec_name().unwrap_or_else(|error| panic!("invalid generated command spec: {error}")) + ); + + #visibility fn #accessor_name() -> &'static #framework::application::CommandSpec { + &#spec_static + } + + #visibility const #command_id_static: &str = #id; + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #mount_name() -> #framework::application::CommandMount { + #framework::application::CommandMount::from_typed_route( + (#spec_static).clone(), + #id, + ) + } + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility static #mount_static: ::std::sync::LazyLock< + #framework::application::CommandMount + > = ::std::sync::LazyLock::new(#mount_name); + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #register_name( + routes: #framework::microsvc::Routes, + ) -> #framework::microsvc::Routes + where + D: #framework::microsvc::CausalRouteDependencies + + Send + + Sync + + 'static, + #aggregate: #framework::Aggregate + Send + Sync + 'static, + #input: #framework::__private::serde::de::DeserializeOwned + Send + 'static, + #outcome: #framework::graphql::CommandOutcome, + { + routes + .typed_command((#command_static).clone()) + .mount((#mount_static).clone()) + .handle(#function_name) + } + + #[allow(unexpected_cfgs)] + #[cfg(feature = "application-runtime")] + #visibility fn #definition_name() -> #framework::application::CommandDefinition { + #framework::application::CommandDefinition::from_typed_command( + (#command_static).clone(), + Some((#mount_static).clone()), + ) + .unwrap_or_else(|error| panic!("invalid generated command definition: {error}")) + } + + #[allow(unexpected_cfgs)] + #[cfg(not(feature = "application-runtime"))] + #visibility fn #definition_name() -> #framework::application::CommandDefinition { + #framework::application::CommandDefinition::from_typed_command( + (#command_static).clone(), + None, + ) + .unwrap_or_else(|error| panic!("invalid generated command definition: {error}")) + } + + #visibility static #definition_static: ::std::sync::LazyLock< + #framework::application::CommandDefinition + > = ::std::sync::LazyLock::new(#definition_name); + }) +} + +fn is_causal_context_type(ty: &Type) -> bool { + let Type::Reference(reference) = ty else { + return false; + }; + let Type::Path(path) = reference.elem.as_ref() else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == "CausalCommandContext") +} + +fn causal_context_aggregate_type(ty: &Type) -> Option { + let Type::Reference(reference) = ty else { + return None; + }; + let Type::Path(path) = reference.elem.as_ref() else { + return None; + }; + let segment = path.path.segments.last()?; + if segment.ident != "CausalCommandContext" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return None; + }; + arguments.args.iter().find_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) +} + +fn validate_prepared_return(output: &ReturnType, outcome: &Type) -> syn::Result<()> { + let Some((_, output)) = (match output { + ReturnType::Type(arrow, output) => Some((arrow, output.as_ref())), + ReturnType::Default => None, + }) else { + return Err(syn::Error::new_spanned( + output, + "typed command handler must return `Result, HandlerError>`", + )); + }; + let Type::Path(result) = output else { + return Err(syn::Error::new_spanned( + output, + "typed command handler must return `Result, HandlerError>`", + )); + }; + let Some(result_segment) = result.path.segments.last() else { + return Err(syn::Error::new_spanned(output, "missing Result return type")); + }; + if result_segment.ident != "Result" { + return Err(syn::Error::new_spanned( + output, + "typed command handler return type must be Result, HandlerError>", + )); + } + let PathArguments::AngleBracketed(arguments) = &result_segment.arguments else { + return Err(syn::Error::new_spanned( + output, + "typed command handler Result must declare PreparedCommand and HandlerError types", + )); + }; + let mut types = arguments.args.iter().filter_map(|argument| match argument { + syn::GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let Some(prepared) = types.next() else { + return Err(syn::Error::new_spanned(output, "missing PreparedCommand return type")); + }; + let Some(error) = types.next() else { + return Err(syn::Error::new_spanned(output, "missing HandlerError return type")); + }; + let Type::Path(prepared) = prepared else { + return Err(syn::Error::new_spanned( + prepared, + "first Result type must be PreparedCommand", + )); + }; + let Some(prepared_segment) = prepared.path.segments.last() else { + return Err(syn::Error::new_spanned(prepared, "missing PreparedCommand type")); + }; + if prepared_segment.ident != "PreparedCommand" { + return Err(syn::Error::new_spanned( + prepared, + "first Result type must be PreparedCommand", + )); + } + let PathArguments::AngleBracketed(prepared_args) = &prepared_segment.arguments else { + return Err(syn::Error::new_spanned( + prepared, + "PreparedCommand must declare its outcome type", + )); + }; + let Some(syn::GenericArgument::Type(actual_outcome)) = prepared_args.args.first() else { + return Err(syn::Error::new_spanned( + prepared, + "PreparedCommand must declare its outcome type", + )); + }; + if !same_type(actual_outcome, outcome) { + return Err(syn::Error::new_spanned( + actual_outcome, + "PreparedCommand outcome does not match the declared `outcome = ...` type", + )); + } + let Type::Path(error) = error else { + return Err(syn::Error::new_spanned( + error, + "second Result type must be HandlerError", + )); + }; + if error + .path + .segments + .last() + .is_none_or(|segment| segment.ident != "HandlerError") + { + return Err(syn::Error::new_spanned( + error, + "second Result type must be HandlerError", + )); + } + Ok(()) +} + +fn same_type(left: &Type, right: &Type) -> bool { + quote!(#left).to_string().replace(' ', "") == quote!(#right).to_string().replace(' ', "") +} + +fn infer_prepared_outcome(output: &ReturnType) -> Option { + let ReturnType::Type(_, output) = output else { + return None; + }; + let Type::Path(path) = output.as_ref() else { + return None; + }; + let result = path.path.segments.last()?; + if result.ident != "Result" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &result.arguments else { + return None; + }; + let first = arguments.args.first()?; + let syn::GenericArgument::Type(Type::Path(prepared)) = first else { + return None; + }; + let prepared = prepared.path.segments.last()?; + if prepared.ident != "PreparedCommand" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &prepared.arguments else { + return None; + }; + match arguments.args.first()? { + syn::GenericArgument::Type(output) => Some(output.clone()), + _ => None, + } +} diff --git a/distributed_macros/src/command_input_defaults.rs b/distributed_macros/src/command_input_defaults.rs index ae18bc0a..24bebfd5 100644 --- a/distributed_macros/src/command_input_defaults.rs +++ b/distributed_macros/src/command_input_defaults.rs @@ -16,7 +16,10 @@ mod keyword { pub fn expand_input_defaults(input: proc_macro::TokenStream) -> proc_macro::TokenStream { match syn::parse::(input) { - Ok(defaults) => defaults.expand().into(), + Ok(defaults) => match crate::shared::framework_path() { + Ok(framework) => defaults.expand(framework).into(), + Err(error) => error.to_compile_error().into(), + }, Err(error) => error.to_compile_error().into(), } } @@ -55,14 +58,14 @@ impl Parse for CommandInputDefaults { } impl CommandInputDefaults { - fn expand(self) -> TokenStream { + fn expand(self, framework: TokenStream) -> TokenStream { let input = self.input; let defaults = self .defaults .into_iter() - .map(|default| default.expand(&input)); + .map(|default| default.expand(&input, &framework)); quote! { - distributed::graphql::__command_input_defaults::<#input>( + #framework::graphql::__command_input_defaults::<#input>( vec![#(#defaults),*] ) } @@ -110,7 +113,7 @@ impl InputDefault { Ok(Self { field, generator }) } - fn expand(self, input: &Path) -> TokenStream { + fn expand(self, input: &Path, framework: &TokenStream) -> TokenStream { let marker_name = format_ident!( "__Distributed{}EffectInputField_{}", input.segments.last().unwrap().ident, @@ -120,10 +123,10 @@ impl InputDefault { let marker = marker_path(input, marker_name); match self.generator { InputDefaultGenerator::UuidV7 => quote! { - distributed::graphql::__input_default_uuid_v7::<#input, #marker>() + #framework::graphql::__input_default_uuid_v7::<#input, #marker>() }, InputDefaultGenerator::Ulid => quote! { - distributed::graphql::__input_default_ulid::<#input, #marker>() + #framework::graphql::__input_default_ulid::<#input, #marker>() }, } } diff --git a/distributed_macros/src/digest.rs b/distributed_macros/src/digest.rs index 523ab731..fa537874 100644 --- a/distributed_macros/src/digest.rs +++ b/distributed_macros/src/digest.rs @@ -4,14 +4,16 @@ use syn::{parse::Parser, Expr, Ident, ItemFn, LitStr, Token}; use crate::shared::{ ensure_sourced_result_signature, extract_params_with_types, generate_digest_call, - wrap_result_body_with_guard, + framework_path, wrap_result_body_with_guard, }; pub(crate) fn expand_digest(attr: TokenStream2, item: TokenStream2) -> syn::Result { let args = parse_digest_args.parse2(attr)?; let mut func = syn::parse2::(item)?; - let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "digest")?; + let framework = framework_path()?; + let signature_synthesized = + ensure_sourced_result_signature(&mut func.sig, "digest", &framework)?; let params = extract_params_with_types(&func.sig, "digest")?; let param_names: Vec<&Ident> = params.iter().map(|(name, _)| name).collect(); diff --git a/distributed_macros/src/domain_event.rs b/distributed_macros/src/domain_event.rs index 5e4cbc7a..b03be56f 100644 --- a/distributed_macros/src/domain_event.rs +++ b/distributed_macros/src/domain_event.rs @@ -5,7 +5,7 @@ use syn::{Data, DeriveInput, Fields, LitInt, LitStr}; use crate::shared::{ canonical_object_schema, projection_body_metadata_tokens, schema_fingerprint, - validate_domain_event_name_literal, + validate_domain_event_name_literal, framework_path, }; pub(crate) fn derive_domain_event(input: TokenStream) -> TokenStream { @@ -16,6 +16,7 @@ pub(crate) fn derive_domain_event(input: TokenStream) -> TokenStream { } pub(crate) fn expand_domain_event(input: DeriveInput) -> syn::Result { + let framework = framework_path()?; let (event_name, version) = parse_domain_event_descriptor(&input)?; let fields = named_fields(&input)?; if !input.generics.params.is_empty() { @@ -46,6 +47,7 @@ pub(crate) fn expand_domain_event(input: DeriveInput) -> syn::Result syn::Result syn::Result distributed::DomainEventDescriptor { - ::DESCRIPTOR.clone() + fn descriptor() -> #framework::DomainEventDescriptor { + ::DESCRIPTOR.clone() } } - impl distributed::domain_event::DomainEventBodyContract for #name {} + impl #framework::domain_event::DomainEventBodyContract for #name {} - impl distributed::projection::lower::ProjectionBodyMetadata for #name { + impl #framework::projection::lower::ProjectionBodyMetadata for #name { #projection_metadata } }) diff --git a/distributed_macros/src/domain_state.rs b/distributed_macros/src/domain_state.rs index bf4e02ae..37ed47a4 100644 --- a/distributed_macros/src/domain_state.rs +++ b/distributed_macros/src/domain_state.rs @@ -3,7 +3,9 @@ use proc_macro2::Span; use quote::quote; use syn::{Data, DeriveInput, Fields, LitInt, LitStr}; -use crate::shared::{canonical_object_schema, projection_body_metadata_tokens, schema_fingerprint}; +use crate::shared::{ + canonical_object_schema, framework_path, projection_body_metadata_tokens, schema_fingerprint, +}; pub(crate) fn derive_domain_state(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); @@ -13,6 +15,7 @@ pub(crate) fn derive_domain_state(input: TokenStream) -> TokenStream { } pub(crate) fn expand_domain_state(input: DeriveInput) -> syn::Result { + let framework = framework_path()?; let version = parse_domain_state_version(&input)?; let fields = named_fields(&input)?; if !input.generics.params.is_empty() { @@ -43,6 +46,7 @@ pub(crate) fn expand_domain_state(input: DeriveInput) -> syn::Result syn::Result syn::Result syn::Result { let args = parse_enqueue_args.parse2(attr)?; let mut func = syn::parse2::(item)?; - let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "enqueue")?; + let framework = framework_path()?; + let signature_synthesized = + ensure_sourced_result_signature(&mut func.sig, "enqueue", &framework)?; let emitter_field = &args.emitter_field; let event_name = &args.event_name; diff --git a/distributed_macros/src/graphql_types.rs b/distributed_macros/src/graphql_types.rs index 96b1ec56..dc2d5095 100644 --- a/distributed_macros/src/graphql_types.rs +++ b/distributed_macros/src/graphql_types.rs @@ -97,19 +97,23 @@ impl RenameRule { } pub fn expand_graphql_input(input: DeriveInput) -> syn::Result { + let framework = crate::shared::framework_path()?; expand( input, - quote! { distributed::graphql::GraphqlInputType }, - quote! { distributed::graphql::GraphqlInputType }, + quote! { #framework::graphql::GraphqlInputType }, + quote! { #framework::graphql::GraphqlInputType }, + framework, SerdeDirection::Deserialize, ) } pub fn expand_graphql_output(input: DeriveInput) -> syn::Result { + let framework = crate::shared::framework_path()?; expand( input, - quote! { distributed::graphql::GraphqlOutputType }, - quote! { distributed::graphql::GraphqlOutputType }, + quote! { #framework::graphql::GraphqlOutputType }, + quote! { #framework::graphql::GraphqlOutputType }, + framework, SerdeDirection::Serialize, ) } @@ -118,6 +122,7 @@ fn expand( input: DeriveInput, trait_path: TokenStream, nested_trait: TokenStream, + framework: TokenStream, serde_direction: SerdeDirection, ) -> syn::Result { let name = &input.ident; @@ -159,17 +164,17 @@ fn expand( let (type_name, nullable, list, item_nullable, nested) = map_type(&field.ty, field, &nested_trait)?; let effect_path_kind = if !list && nested.is_some() { - quote! { distributed::graphql::EffectInputObjectKind } + quote! { #framework::graphql::EffectInputObjectKind } } else { - quote! { distributed::graphql::EffectInputTerminalKind } + quote! { #framework::graphql::EffectInputTerminalKind } }; - let effect_wire = effect_input_wire_tokens(&type_name, list, nested.is_some()); + let effect_wire = effect_input_wire_tokens(&framework, &type_name, list, nested.is_some()); let nested_tokens = match nested { Some(tokens) => quote! { Some(::std::boxed::Box::new(#tokens)) }, None => quote! { None }, }; field_tokens.push(quote! { - distributed::graphql::GraphqlTypeField { + #framework::graphql::GraphqlTypeField { name: #field_name_str.to_string(), type_name: #type_name.to_string(), nullable: #nullable, @@ -184,16 +189,16 @@ fn expand( let nested_ty = effect_nested_type(field_ty); let non_null_ty = effect_non_null_type(field_ty); let nullability = if extract_path_arg(field_ty, "Option").is_some() { - quote! { distributed::graphql::EffectNullable } + quote! { #framework::graphql::EffectNullable } } else { - quote! { distributed::graphql::EffectRequired } + quote! { #framework::graphql::EffectRequired } }; effect_input_markers.push(quote! { #[doc(hidden)] #[allow(non_camel_case_types)] #visibility struct #marker; - impl distributed::graphql::EffectInputFieldMarker for #marker { + impl #framework::graphql::EffectInputFieldMarker for #marker { type Input = #name; type Value = #field_ty; type NonNullValue = #non_null_ty; @@ -212,8 +217,8 @@ fn expand( let type_name_str = name.to_string(); Ok(quote! { impl #trait_path for #name { - fn graphql_type() -> distributed::graphql::GraphqlTypeDef { - distributed::graphql::GraphqlTypeDef::new( + fn graphql_type() -> #framework::graphql::GraphqlTypeDef { + #framework::graphql::GraphqlTypeDef::new( #type_name_str, vec![#(#field_tokens),*], ).with_type_id(::std::any::TypeId::of::<#name>()) @@ -224,22 +229,27 @@ fn expand( }) } -fn effect_input_wire_tokens(type_name: &str, list: bool, nested: bool) -> proc_macro2::TokenStream { +fn effect_input_wire_tokens( + framework: &TokenStream, + type_name: &str, + list: bool, + nested: bool, +) -> proc_macro2::TokenStream { if list { - return quote! { distributed::graphql::EffectWireList }; + return quote! { #framework::graphql::EffectWireList }; } if nested { - return quote! { distributed::graphql::EffectWireObject }; + return quote! { #framework::graphql::EffectWireObject }; } match type_name { - "String" | "ID" => quote! { distributed::graphql::EffectWireString }, - "Boolean" => quote! { distributed::graphql::EffectWireBoolean }, - "BigInt" | "Int" => quote! { distributed::graphql::EffectWireBigInt }, - "Float" => quote! { distributed::graphql::EffectWireFloat }, - "JSON" => quote! { distributed::graphql::EffectWireJson }, - "Bytea" => quote! { distributed::graphql::EffectWireBytea }, - "Timestamptz" => quote! { distributed::graphql::EffectWireTimestamp }, - _ => quote! { distributed::graphql::EffectWireUnsupported }, + "String" | "ID" => quote! { #framework::graphql::EffectWireString }, + "Boolean" => quote! { #framework::graphql::EffectWireBoolean }, + "BigInt" | "Int" => quote! { #framework::graphql::EffectWireBigInt }, + "Float" => quote! { #framework::graphql::EffectWireFloat }, + "JSON" => quote! { #framework::graphql::EffectWireJson }, + "Bytea" => quote! { #framework::graphql::EffectWireBytea }, + "Timestamptz" => quote! { #framework::graphql::EffectWireTimestamp }, + _ => quote! { #framework::graphql::EffectWireUnsupported }, } } diff --git a/distributed_macros/src/lib.rs b/distributed_macros/src/lib.rs index 0dc1645d..23305bd5 100644 --- a/distributed_macros/src/lib.rs +++ b/distributed_macros/src/lib.rs @@ -1,10 +1,13 @@ mod aggregate; +mod application; +mod command; mod command_input_defaults; mod digest; mod domain_event; mod domain_state; mod enqueue; mod graphql_types; +mod module; mod mutation; // Event-owning `projection!` authoring removed (mutation projectors cutover). mod read_model; @@ -15,6 +18,31 @@ mod sourced; use proc_macro::TokenStream; use syn::DeriveInput; +/// Generate one typed command's portable contract and optional executable +/// mount from the same handler declaration. +#[proc_macro_attribute] +pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { + command::expand(attr.into(), item.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + +/// Register an explicit logical module. +#[proc_macro] +pub fn module(input: TokenStream) -> TokenStream { + module::expand(input.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + +/// Register an explicit application and its selected modules/surfaces. +#[proc_macro] +pub fn application(input: TokenStream) -> TokenStream { + application::expand(input.into()) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + /// Attribute macro that automatically queues a local event for emission. #[proc_macro_attribute] pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { diff --git a/distributed_macros/src/module.rs b/distributed_macros/src/module.rs new file mode 100644 index 00000000..a1df31f3 --- /dev/null +++ b/distributed_macros/src/module.rs @@ -0,0 +1,165 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, Ident, LitStr, Token, Type, Visibility}; + +struct ModuleInput { + visibility: Visibility, + name: Ident, + id: LitStr, + commands: Vec, + projections: Vec, + surfaces: Vec, + capabilities: Vec, +} + +impl Parse for ModuleInput { + fn parse(input: ParseStream<'_>) -> syn::Result { + let visibility: Visibility = input.parse()?; + let name: Ident = input.parse()?; + if input.peek(Token![:]) { + input.parse::()?; + let _: Type = input.parse()?; + } + if input.peek(Token![=]) { + input.parse::()?; + } + let content; + syn::braced!(content in input); + let mut output = Self { + visibility, + name, + id: LitStr::new("", proc_macro2::Span::call_site()), + commands: Vec::new(), + projections: Vec::new(), + surfaces: Vec::new(), + capabilities: Vec::new(), + }; + while !content.is_empty() { + let field: Ident = content.parse()?; + content.parse::()?; + match field.to_string().as_str() { + "id" => output.id = content.parse()?, + "commands" => output.commands = parse_expr_array(&content)?, + "mounts" => { + return Err(syn::Error::new( + field.span(), + "command declarations derive their optional runtime mount; maintain one `commands: [*_DEFINITION]` list and remove `mounts`", + )) + } + "projections" => output.projections = parse_expr_array(&content)?, + "surfaces" => output.surfaces = parse_expr_array(&content)?, + "capabilities" | "required_capabilities" => { + output.capabilities = parse_expr_array(&content)? + } + other => { + return Err(syn::Error::new( + field.span(), + format!("unknown module field `{other}`"), + )) + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + if output.id.value().is_empty() { + output.id = LitStr::new( + &output.name.to_string().to_ascii_lowercase(), + output.name.span(), + ); + } + Ok(output) + } +} + +fn parse_expr_array(input: ParseStream<'_>) -> syn::Result> { + let content; + syn::bracketed!(content in input); + Ok(Punctuated::::parse_terminated(&content)? + .into_iter() + .collect()) +} + +pub fn expand(input: proc_macro2::TokenStream) -> syn::Result { + let framework = crate::shared::framework_path()?; + let input = syn::parse2::(input)?; + let ModuleInput { + visibility, + name, + id, + commands, + projections, + surfaces, + capabilities, + } = input; + let accessor = format_ident!("{}", name.to_string().to_lowercase()); + let command_ids = commands + .iter() + .map(command_definition_id) + .collect::>>()?; + let commands = commands.iter().map(|value| quote! { (&*#value).clone() }); + let projections = projections + .iter() + .map(|value| quote! { (&*#value).clone() }); + let surfaces = surfaces.iter().map(|value| quote! { (&*#value).clone() }); + let capabilities = capabilities + .iter() + .map(|value| quote! { #value }) + .collect::>(); + let capability_builder = if capabilities.is_empty() { + quote! {} + } else { + quote! { .required_capabilities([#(#capabilities),*]) } + }; + Ok(quote! { + const _: () = #framework::application::assert_unique_command_ids( + &[#(#command_ids),*] + ); + + #visibility static #name: ::std::sync::LazyLock<#framework::application::Module> = + ::std::sync::LazyLock::new(|| { + #framework::application::Module::builder(#id) + .command_definitions([#(#commands),*]) + .projections([#(#projections),*]) + .surfaces([#(#surfaces),*]) + #capability_builder + .build() + .unwrap_or_else(|error| panic!("invalid generated module: {error}")) + }); + + #visibility fn #accessor() -> &'static #framework::application::Module { + &#name + } + }) +} + +fn command_definition_id(value: &Expr) -> syn::Result { + let Expr::Path(path) = value else { + return Err(syn::Error::new_spanned( + value, + "module command entries must be generated *_DEFINITION paths", + )); + }; + let mut id_path = path.path.clone(); + let Some(last) = id_path.segments.last_mut() else { + return Err(syn::Error::new_spanned( + value, + "module command entries must be generated *_DEFINITION paths", + )); + }; + if !last.ident.to_string().ends_with("_DEFINITION") { + return Err(syn::Error::new_spanned( + value, + "module command entries must use the generated *_DEFINITION value so spec and mount cannot diverge", + )); + } + let span = last.ident.span(); + let name = last.ident.to_string(); + let name = name + .strip_suffix("_DEFINITION") + .expect("checked generated definition suffix"); + last.ident = syn::Ident::new(&format!("{name}_COMMAND_ID"), span); + Ok(id_path) +} diff --git a/distributed_macros/src/shared.rs b/distributed_macros/src/shared.rs index 9c96f32d..b0b8ff0d 100644 --- a/distributed_macros/src/shared.rs +++ b/distributed_macros/src/shared.rs @@ -1,3 +1,4 @@ +use proc_macro_crate::{crate_name, FoundCrate}; use quote::{quote, ToTokens}; use sha2::{Digest, Sha256}; use syn::{ @@ -8,6 +9,28 @@ use syn::{ // Shared helpers // ============================================================================ +/// Resolve the framework crate as it is named by the consuming package. +/// +/// Proc-macro output is compiled in the caller's crate, so a literal +/// `::distributed` path is wrong when the dependency is renamed or re-exported. +/// `FoundCrate::Itself` also keeps framework-internal macro tests hygienic. +pub(crate) fn framework_path() -> syn::Result { + match crate_name("distributed") { + Ok(FoundCrate::Itself) => Ok(quote!(crate)), + Ok(FoundCrate::Name(name)) => { + let ident = syn::Ident::new(&name, proc_macro2::Span::call_site()); + Ok(quote!(::#ident)) + } + Err(error) => Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "unable to resolve the `distributed` dependency for generated code: {}; add the framework dependency or re-export it under that package", + error + ), + )), + } +} + /// Extract parameter names and types from a method signature (excludes `self`). /// /// Every parameter must be a plain identifier: its name is recorded in the @@ -53,10 +76,11 @@ fn returns_result(sig: &syn::Signature) -> bool { pub(crate) fn ensure_sourced_result_signature( sig: &mut syn::Signature, attr_name: &str, + framework: &proc_macro2::TokenStream, ) -> Result { match &sig.output { ReturnType::Default => { - sig.output = syn::parse_quote!(-> distributed::SourcedResult<()>); + sig.output = syn::parse_quote!(-> #framework::SourcedResult<()>); Ok(true) } ReturnType::Type(_, _) if returns_result(sig) => Ok(false), @@ -338,6 +362,7 @@ fn projection_field_metadata_with_rename<'a>( } pub(crate) fn projection_body_metadata_tokens( + framework: &proc_macro2::TokenStream, role: &str, type_name: &str, version: u64, @@ -378,12 +403,12 @@ pub(crate) fn projection_body_metadata_tokens( let present = field.present; let always_present = field.always_present; quote! { - distributed::projection::lower::ProjectionBodyFieldMetadata { + #framework::projection::lower::ProjectionBodyFieldMetadata { rust_name: #rust_name, wire_name: #wire_name, rust_type: #rust_type, portable_type: - distributed::projection::lower::ProjectionPortableType::#portable_kind, + #framework::projection::lower::ProjectionPortableType::#portable_kind, nullable: #nullable, present: #present, always_present: #always_present, @@ -392,7 +417,7 @@ pub(crate) fn projection_body_metadata_tokens( }); Ok(quote! { const PROJECTION_FIELDS: &'static [ - distributed::projection::lower::ProjectionBodyFieldMetadata + #framework::projection::lower::ProjectionBodyFieldMetadata ] = &[#(#entries),*]; const PROJECTION_SCHEMA_FINGERPRINT: &'static str = #fingerprint; }) diff --git a/distributed_macros/src/sourced.rs b/distributed_macros/src/sourced.rs index b0acb840..61a5c041 100644 --- a/distributed_macros/src/sourced.rs +++ b/distributed_macros/src/sourced.rs @@ -12,7 +12,7 @@ use crate::aggregate::{ }; use crate::shared::{ canonical_object_schema, ensure_sourced_result_signature, extract_params_with_types, - generate_digest_call, generate_enqueue_call, projection_body_metadata_tokens, + framework_path, generate_digest_call, generate_enqueue_call, projection_body_metadata_tokens, schema_fingerprint, validate_domain_event_name_literal, wrap_result_body_with_guard_and_postlude, }; @@ -214,6 +214,15 @@ struct EventMethodInfo { event_name: LitStr, method_name: Ident, params: Vec<(Ident, syn::Type)>, + /// Present when this recorder has `domain` and therefore a generated + /// outward domain-event marker type. + domain_event_type: Option, +} + +/// Public aggregate method that may capture one or more domain events. +struct DomainCommandTransition { + method_name: Ident, + domain_event_types: Vec, } struct DomainExpansion { @@ -314,6 +323,7 @@ fn expand_domain_capture( }; let version = event_version(event_attr.version.as_ref()); let event_name = &event_attr.event_name; + let framework = framework_path()?; match mode { DomainMode::State => { @@ -382,6 +392,7 @@ fn expand_domain_capture( #(#projection_field_definitions),* }))?; let projection_metadata = projection_body_metadata_tokens( + &framework, "domain_event", &body_type_name, version.base10_parse::()?, @@ -630,6 +641,7 @@ fn expand_domain_capture( } pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let framework = framework_path()?; let args = parse_sourced_args.parse2(attr)?; let mut impl_block = syn::parse2::(item)?; @@ -722,7 +734,7 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res ensure_domain_body_has_no_early_exit(&method.block)?; } let signature_synthesized = - ensure_sourced_result_signature(&mut method.sig, "event")?; + ensure_sourced_result_signature(&mut method.sig, "event", &framework)?; let params = extract_params_with_types(&method.sig, "event")?; let param_name_refs: Vec<&Ident> = @@ -773,10 +785,20 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res ); method.block = new_body; + let domain_event_type = if event_attr.domain.is_some() { + Some(identity_domain_event_type( + &struct_name, + &event_attr.event_name, + )?) + } else { + None + }; + event_methods.push(EventMethodInfo { event_name: event_attr.event_name, method_name: method.sig.ident.clone(), params, + domain_event_type, }); } Ok(None) => { /* not an event method, skip */ } @@ -785,6 +807,9 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res } } + let domain_command_transitions = + discover_domain_command_transitions(&impl_block, &event_methods); + let deletion_identity = if uses_deletion_identity { let identity = format_ident!("{struct_name}DomainIdentity"); let deletion_type_name = format!("DomainDeletion<{identity}>"); @@ -964,6 +989,9 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res &upcasters_method, ); + let domain_commands_module = + expand_domain_commands_module(&struct_name, &domain_command_transitions); + let expanded = quote! { #impl_block #enum_def @@ -971,6 +999,7 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res #try_from_impl #(#generated_domain_types)* #deletion_identity + #domain_commands_module #upcaster_wrappers #aggregate_impl }; @@ -978,6 +1007,150 @@ pub(crate) fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Res Ok(expanded) } +fn discover_domain_command_transitions( + impl_block: &ItemImpl, + event_methods: &[EventMethodInfo], +) -> Vec { + let recorders: std::collections::BTreeMap = event_methods + .iter() + .filter_map(|event| { + event + .domain_event_type + .clone() + .map(|domain_event_type| (event.method_name.to_string(), domain_event_type)) + }) + .collect(); + if recorders.is_empty() { + return Vec::new(); + } + + let mut transitions = Vec::new(); + for item in &impl_block.items { + let syn::ImplItem::Fn(method) = item else { + continue; + }; + // Domain recorders themselves are not command transitions. + if recorders.contains_key(&method.sig.ident.to_string()) { + continue; + } + if !matches!(method.vis, syn::Visibility::Public(_)) { + continue; + } + + let mut finder = DomainEventCallFinder { + recorders: &recorders, + found: std::collections::BTreeMap::new(), + }; + finder.visit_block(&method.block); + if finder.found.is_empty() { + continue; + } + transitions.push(DomainCommandTransition { + method_name: method.sig.ident.clone(), + domain_event_types: finder.found.into_values().collect(), + }); + } + transitions +} + +struct DomainEventCallFinder<'a> { + recorders: &'a std::collections::BTreeMap, + found: std::collections::BTreeMap, +} + +impl<'ast> Visit<'ast> for DomainEventCallFinder<'_> { + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if is_self_receiver(&node.receiver) { + if let Some(domain_event_type) = self.recorders.get(&node.method.to_string()) { + self.found + .entry(domain_event_type.to_string()) + .or_insert_with(|| domain_event_type.clone()); + } + } + syn::visit::visit_expr_method_call(self, node); + } +} + +fn is_self_receiver(expression: &Expr) -> bool { + match expression { + Expr::Path(path) => path.path.is_ident("self"), + Expr::Paren(paren) => is_self_receiver(&paren.expr), + Expr::Group(group) => is_self_receiver(&group.expr), + _ => false, + } +} + +fn method_name_to_type_ident(method_name: &Ident) -> Ident { + let pascal = method_name + .to_string() + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::(); + format_ident!("{}", pascal) +} + +fn expand_domain_commands_module( + aggregate: &Ident, + transitions: &[DomainCommandTransition], +) -> TokenStream2 { + if transitions.is_empty() { + return TokenStream2::new(); + } + + let transition_items = transitions.iter().map(|transition| { + let type_name = method_name_to_type_ident(&transition.method_name); + let method_name = transition.method_name.to_string(); + let aggregate_name = aggregate.to_string(); + let event_types = &transition.domain_event_types; + let doc = format!( + "Outward domain-event set for `{aggregate_name}::{method_name}`.\n\n\ + Derived from direct `self.()` calls to `#[event(..., domain)]` \ + methods in this `#[sourced]` impl. Use with \ + [`distributed::graphql::TypedCommand::emits_events`]." + ); + quote! { + #[doc = #doc] + pub enum #type_name {} + + impl distributed::graphql::CommandEventSet for #type_name { + fn command_event_set() -> distributed::graphql::CommandProjectionEventSet { + distributed::graphql::__command_projection_events([ + #( + distributed::graphql::__command_projection_event_descriptor::< + super::#event_types, + >() + ),* + ]) + } + } + } + }); + + let aggregate_name = aggregate.to_string(); + let module_doc = format!( + "Domain command transitions for `{aggregate_name}`.\n\n\ + Each type is a zero-sized witness for the outward domain events a public \ + aggregate method may capture. Prefer \ + `typed_command(...).emits_events::()` over a \ + hand-duplicated `events![...]` list when the domain method already owns \ + the transition." + ); + + quote! { + #[doc = #module_doc] + pub mod domain_commands { + #(#transition_items)* + } + } +} + // ============================================================================ // #[derive(ReadModel)] derive macro // ============================================================================ diff --git a/distributed_macros/tests/application.rs b/distributed_macros/tests/application.rs new file mode 100644 index 00000000..3fb9f114 --- /dev/null +++ b/distributed_macros/tests/application.rs @@ -0,0 +1,124 @@ +#![allow(unexpected_cfgs)] +#![allow(dead_code)] +#![allow(unused_imports)] + +use distributed::graphql::Succeeded; +use distributed::microsvc::{CausalCommandContext, HandlerError}; +use distributed::{Aggregate, DomainEvent, Entity, EventRecord, GraphqlInput, GraphqlOutput}; +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +pub struct FixtureAggregate { + entity: Entity, +} + +impl Aggregate for FixtureAggregate { + type ReplayError = String; + + fn aggregate_type() -> &'static str { + "application-macro-fixture" + } + + fn entity(&self) -> &Entity { + &self.entity + } + + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + + fn replay_event(&mut self, _event: &EventRecord) -> Result<(), Self::ReplayError> { + Ok(()) + } +} + +#[derive(Clone, Deserialize, GraphqlInput)] +pub struct CreateInput { + id: String, + title: String, +} + +#[derive(Clone, Serialize, GraphqlOutput)] +pub struct CreateOutput { + id: String, +} + +#[derive(Clone, Serialize, DomainEvent)] +#[domain_event(name = "todo.created", version = 1)] +pub struct TodoCreated { + id: String, +} + +#[distributed::command( + id = "todo.create", + roles(user, admin), + emits(TodoCreated), + applies(distributed::event_preview! { + TodoCreated => TodoCreated { + id: input.id, + ..unknown + } + }), + default(title = uuid_v7), + input = CreateInput, + outcome = Succeeded +)] +pub async fn handle( + _context: &CausalCommandContext<'_, FixtureAggregate>, + _input: CreateInput, +) -> Result>, HandlerError> { + unimplemented!() +} + +distributed::module! { + pub TODO_MODULE { + id: "todo", + commands: [HANDLE_DEFINITION], + capabilities: ["events"], + } +} + +distributed::application! { + pub TODO_APPLICATION { + id: "todo-app", + modules: [TODO_MODULE], + capabilities: ["identity"], + } +} + +distributed::application! { + pub IMPLICIT_APPLICATION { + modules: [TODO_MODULE], + } +} + +#[test] +fn command_module_and_application_macros_share_one_portable_spec() { + let spec = handle_spec().expect("generated command spec"); + assert_eq!(spec.id, "todo.create"); + assert_eq!(spec.roles, ["admin", "user"]); + assert_eq!(spec.emits[0].name, "todo.created"); + assert!(spec.applies.as_array().is_some_and(|values| !values.is_empty())); + assert!(spec.defaults.as_array().is_some_and(|values| !values.is_empty())); + assert!(!spec.effects.is_null()); + assert!(!spec.fingerprint.is_empty()); + assert_eq!( + spec.canonical_bytes().unwrap(), + spec.canonical_bytes().unwrap() + ); + + assert_eq!(TODO_MODULE.manifest().commands[0], spec); + assert_eq!(TODO_APPLICATION.manifest().modules[0].id, "todo"); + assert_eq!(TODO_APPLICATION.manifest().name, "todo-app"); + assert_eq!( + TODO_APPLICATION.manifest().required_capabilities, + ["events", "identity"] + ); + assert_eq!(IMPLICIT_APPLICATION.manifest().name, "implicit_application"); + + #[cfg(feature = "application-runtime")] + { + assert_eq!(HANDLE_MOUNT.spec().id, "todo.create"); + assert_eq!(HANDLE_MOUNT.spec().fingerprint, spec.fingerprint); + } +} diff --git a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs new file mode 100644 index 00000000..2cdff592 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.rs @@ -0,0 +1,20 @@ +struct MismatchAggregate; +struct ExpectedInput; +struct ActualInput; + +#[distributed::command( + id = "todo.mismatch", + input = ExpectedInput, + outcome = distributed::graphql::Succeeded +)] +async fn declared_type_mismatch( + _context: &distributed::microsvc::CausalCommandContext<'_, MismatchAggregate>, + _input: ActualInput, +) -> Result< + distributed::graphql::PreparedCommand>, + distributed::microsvc::HandlerError, +> { + unreachable!() +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr new file mode 100644 index 00000000..5b581fe7 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_declared_type_mismatch.stderr @@ -0,0 +1,5 @@ +error: handler input parameter does not match the declared `input = ...` type + --> tests/compile_fail/application_command_declared_type_mismatch.rs:12:13 + | +12 | _input: ActualInput, + | ^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs new file mode 100644 index 00000000..38c11ade --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_id.rs @@ -0,0 +1,45 @@ +use std::sync::LazyLock; + +use distributed::application::{CommandDefinition, CommandSpec, CommandTypeField, CommandTypeSpec}; +use distributed::graphql::CommandConsistency; + +fn spec() -> CommandSpec { + CommandSpec::try_new( + "todo.duplicate", + "todo_duplicate", + CommandTypeSpec { + name: "DuplicateInput".into(), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: "DuplicateOutput".into(), + fields: Vec::new(), + }, + CommandConsistency::Eventual, + ) + .unwrap() +} + +static FIRST_DEFINITION: LazyLock = + LazyLock::new(|| CommandDefinition::contract(spec())); +static SECOND_DEFINITION: LazyLock = + LazyLock::new(|| CommandDefinition::contract(spec())); + +const FIRST_COMMAND_ID: &str = "todo.duplicate"; +const SECOND_COMMAND_ID: &str = "todo.duplicate"; + +distributed::module! { + pub DUPLICATE_MODULE { + id: "duplicates", + commands: [FIRST_DEFINITION, SECOND_DEFINITION], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr b/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr new file mode 100644 index 00000000..5377eae5 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_duplicate_id.stderr @@ -0,0 +1,21 @@ +error[E0080]: evaluation panicked: duplicate command identity in module declaration + --> tests/compile_fail/application_command_duplicate_id.rs:38:1 + | +38 | / distributed::module! { +39 | | pub DUPLICATE_MODULE { +40 | | id: "duplicates", +41 | | commands: [FIRST_DEFINITION, SECOND_DEFINITION], +42 | | } +43 | | } + | |_^ evaluation of `_` failed inside this call + | +note: inside `assert_unique_command_ids` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/src/application/mod.rs + | + | panic!("duplicate command identity in module declaration"); + | ---------------------------------------------------------- in this macro invocation diff --git a/distributed_macros/tests/compile_fail/application_command_missing_id.rs b/distributed_macros/tests/compile_fail/application_command_missing_id.rs new file mode 100644 index 00000000..79e63dbc --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_id.rs @@ -0,0 +1,4 @@ +#[distributed::command] +async fn missing_id(_context: (), _input: ()) {} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_missing_id.stderr b/distributed_macros/tests/compile_fail/application_command_missing_id.stderr new file mode 100644 index 00000000..68126840 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_missing_id.stderr @@ -0,0 +1,5 @@ +error: command declaration requires `id = "..."` + --> tests/compile_fail/application_command_missing_id.rs:2:10 + | +2 | async fn missing_id(_context: (), _input: ()) {} + | ^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs new file mode 100644 index 00000000..330615c9 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.rs @@ -0,0 +1,16 @@ +struct WrongAggregate; +struct WrongInput; + +#[distributed::command( + id = "todo.create", + input = WrongInput, + outcome = distributed::graphql::Succeeded +)] +async fn wrong_handler( + _context: &distributed::microsvc::CausalCommandContext<'_, WrongAggregate>, + _input: WrongInput, +) -> Result<(), distributed::microsvc::HandlerError> { + Ok(()) +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr new file mode 100644 index 00000000..7b77905c --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_command_wrong_handler.stderr @@ -0,0 +1,5 @@ +error: first Result type must be PreparedCommand + --> tests/compile_fail/application_command_wrong_handler.rs:12:13 + | +12 | ) -> Result<(), distributed::microsvc::HandlerError> { + | ^^ diff --git a/distributed_macros/tests/compile_fail/application_module_missing_spec.rs b/distributed_macros/tests/compile_fail/application_module_missing_spec.rs new file mode 100644 index 00000000..2f01bf29 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_missing_spec.rs @@ -0,0 +1,9 @@ +distributed::module! { + pub TODO_MODULE { + id: "todo", + commands: [], + mounts: [MISSING_COMMAND_MOUNT], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr b/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr new file mode 100644 index 00000000..d6945e08 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_missing_spec.stderr @@ -0,0 +1,5 @@ +error: command declarations derive their optional runtime mount; maintain one `commands: [*_DEFINITION]` list and remove `mounts` + --> tests/compile_fail/application_module_missing_spec.rs:5:9 + | +5 | mounts: [MISSING_COMMAND_MOUNT], + | ^^^^^^ diff --git a/distributed_macros/tests/compile_fail/application_module_unknown_field.rs b/distributed_macros/tests/compile_fail/application_module_unknown_field.rs new file mode 100644 index 00000000..58750c20 --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_unknown_field.rs @@ -0,0 +1,8 @@ +distributed::module! { + pub TODO_MODULE { + id: "todo", + unknown: [], + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr b/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr new file mode 100644 index 00000000..8557ec0c --- /dev/null +++ b/distributed_macros/tests/compile_fail/application_module_unknown_field.stderr @@ -0,0 +1,5 @@ +error: unknown module field `unknown` + --> tests/compile_fail/application_module_unknown_field.rs:4:9 + | +4 | unknown: [], + | ^^^^^^^ diff --git a/examples/graphiql.rs b/examples/graphiql.rs index 024c67fb..ac4bd1cc 100644 --- a/examples/graphiql.rs +++ b/examples/graphiql.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use distributed::graphql::GraphqlEngine; use distributed::microsvc::{serve, Service}; use distributed::{ - ColumnType, DistributedProjectManifest, PrimaryKey, TableColumn, TableKind, TableSchema, + ColumnType, ReadModelCatalog, PrimaryKey, TableColumn, TableKind, TableSchema, }; use sqlx::sqlite::SqlitePoolOptions; @@ -76,9 +76,9 @@ async fn seed_pool() -> sqlx::SqlitePool { async fn main() -> Result<(), Box> { let addr = std::env::var("GRAPHIQL_ADDR").unwrap_or_else(|_| "127.0.0.1:4000".into()); let pool = seed_pool().await; - let manifest = DistributedProjectManifest::new("graphiql-demo").table_schema(orders_schema()); + let manifest = ReadModelCatalog::new("graphiql-demo").table_schema(orders_schema()); - let engine = GraphqlEngine::from_manifest(&manifest, pool)? + let engine = GraphqlEngine::from_schema_catalog(&manifest, pool)? .roles(&["user", "anonymous"]) .grant_all("user") .graphiql(true) diff --git a/js/README.md b/js/README.md index fa131b16..6fd00462 100644 --- a/js/README.md +++ b/js/README.md @@ -4,7 +4,7 @@ The generated, end-to-end typed client for [Distributed](https://github.com/hops-ops/distributed) services. Rust table, relationship, role, and command definitions produce one authorized -client surface. `dctl client` combines that surface with application GraphQL +client surface. `distributed client` combines that surface with application GraphQL documents and emits typed operations, live companions, route-load plans, and commands. This package executes those artifacts through one normalized, causally consistent browser replica. @@ -37,9 +37,9 @@ by the `/sveltekit` and `/react` entry points, respectively. The service, not the browser, owns authorization and GraphQL semantics: ```bash -dctl client-manifest > target/distributed-client.json +distributed client-manifest > target/distributed-client.json -dctl client \ +distributed client \ --manifest target/distributed-client.json \ --role user \ --documents 'src/**/*.graphql' \ @@ -128,7 +128,7 @@ keeps ordinary application documents out of the admin tree; each trust boundary has its own Rust manifest entrypoint, generated directory, virtual module, and request-local replica. A single-surface application can omit the second entry. -The Vite integration runs `dctl client` at startup/build, watches GraphQL +The Vite integration runs `distributed client` at startup/build, watches GraphQL documents, stages all surfaces, commits a rollback-capable multi-output transaction, then triggers one reload. It exposes the generated Svelte wrapper through the configured virtual module: @@ -440,7 +440,7 @@ command pipeline, or package-owned codegen executable. To move an existing pilot application: -1. rerun `dctl client` and import its operation/command artifacts; +1. rerun `distributed client` and import its operation/command artifacts; 2. compose one replica through the framework adapter or core transport; 3. remove handwritten cache targets, merge/update callbacks, and invalidation policies; diff --git a/js/src/replica/command-runtime.ts b/js/src/replica/command-runtime.ts index b2ead73d..64998cfb 100644 --- a/js/src/replica/command-runtime.ts +++ b/js/src/replica/command-runtime.ts @@ -7,6 +7,7 @@ export { replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, replicaCommandProjectedLifecycleOf, + replicaCommandReadRecord, replicaResultObservation } from './command-runtime/index.js'; export type { diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index f8b7f23e..760c0319 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -49,6 +49,7 @@ import { replicaCommandAuthority, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; import type { @@ -1157,10 +1158,18 @@ export function createReplicaCommandRuntime< ); } try { + const pureHost = { + readRecord: replica[replicaCommandReadRecord]?.bind(replica), + pureFunctions: options.pureFunctions + }; (replica as SemanticReplica).createOptimisticLayer( prepared.commandId, (writer) => - applyOptimisticEffects(writer, prepared.optimistic.operations), + applyOptimisticEffects( + writer, + prepared.optimistic.operations, + pureHost + ), semanticChanges ); } catch (error) { diff --git a/js/src/replica/command-runtime/index.ts b/js/src/replica/command-runtime/index.ts index 38333ff6..88bb07fb 100644 --- a/js/src/replica/command-runtime/index.ts +++ b/js/src/replica/command-runtime/index.ts @@ -3,6 +3,7 @@ export { replicaCommandDirectProjection, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; export type { diff --git a/js/src/replica/command-runtime/lib/effects.ts b/js/src/replica/command-runtime/lib/effects.ts index b4304792..6c745bb4 100644 --- a/js/src/replica/command-runtime/lib/effects.ts +++ b/js/src/replica/command-runtime/lib/effects.ts @@ -3,21 +3,38 @@ import { } from '../../commands.js'; import type { PreparedProjectionOperation, - PreparedProjectionScope + PreparedProjectionScope, + ReplicaPureFunction } from '../../projection-delta/index.js'; import { replicaRecordKey } from '../../identity.js'; import type { ReplicaIndexSemanticChange } from '../../index-maintenance.js'; import type { + ReplicaIdentity, ReplicaModelArtifact, ReplicaOptimisticWriter, ReplicaValue } from '../../types.js'; +export type PureReduceHost = Readonly<{ + /** Read live cache fields for a known record (pre-layer). Fail-closed on miss. */ + readRecord?: ( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ) => Readonly> | undefined; + pureFunctions?: Readonly>; +}>; + +/** + * Expand pure reducers against known cache rows, then apply ordinary ops. + * Missing row / unknown pure / pure null → skip that reduce (no invent). + */ export function applyOptimisticEffects( writer: ReplicaOptimisticWriter, - effects: readonly PreparedProjectionOperation[] + effects: readonly PreparedProjectionOperation[], + host: PureReduceHost = {} ): void { - for (const effect of effects) { + const expanded = expandReduceKnownRecord(effects, host); + for (const effect of expanded) { switch (effect.kind) { case 'upsert': case 'patch': { @@ -44,13 +61,71 @@ export function applyOptimisticEffects( case 'unlink': case 'invalidate_model': case 'invalidate_relationship': + case 'reduce_known_record': // Task 8 consumes the exact semantic context. Guessing a to-one // record link for a to-many relationship would corrupt truth. + // reduce_known_record is expanded above. break; } } } +function expandReduceKnownRecord( + effects: readonly PreparedProjectionOperation[], + host: PureReduceHost +): readonly PreparedProjectionOperation[] { + const out: PreparedProjectionOperation[] = []; + for (const effect of effects) { + if (effect.kind !== 'reduce_known_record') { + out.push(effect); + continue; + } + const pure = host.pureFunctions?.[effect.fn]; + const read = host.readRecord; + if (pure === undefined || read === undefined) { + continue; + } + const model = modelFromKey(effect.scope); + const identity = identityFromKey(effect.scope); + const current = read(model, identity); + if (current === undefined) { + continue; + } + let next: Readonly> | null; + try { + next = pure(current, effect.args); + } catch { + continue; + } + if (next === null) { + continue; + } + const fields: Record = Object.create(null) as Record< + string, + ReplicaValue + >; + for (const field of effect.assign) { + if (!Object.prototype.hasOwnProperty.call(next, field)) { + continue; + } + fields[field] = next[field] as ReplicaValue; + } + if (Object.keys(fields).length === 0) { + continue; + } + out.push( + Object.freeze({ + kind: 'patch' as const, + scope: effect.scope, + fields: Object.freeze(fields), + unset: Object.freeze([]) as readonly string[], + ifPresent: true as const + }) + ); + } + return Object.freeze(out); +} + export function preparedSemanticChanges( prepared: ReplicaPreparedCommand ): readonly ReplicaIndexSemanticChange[] { @@ -61,9 +136,10 @@ export function preparedSemanticChanges( case 'upsert': case 'patch': case 'delete': + case 'reduce_known_record': // DistributedReplica captures ordinary writer mutations into the // same layer context. Supplying them again would double-apply the - // semantic record operation. + // semantic record operation. Pure reduce expands to patch first. break; case 'link': case 'unlink': { @@ -126,6 +202,7 @@ export function preparedDispatchKeys( case 'upsert': case 'patch': case 'delete': + case 'reduce_known_record': addScope(effect.scope); break; case 'link': diff --git a/js/src/replica/command-runtime/lib/util.ts b/js/src/replica/command-runtime/lib/util.ts index ecc46d05..cb2ce31e 100644 --- a/js/src/replica/command-runtime/lib/util.ts +++ b/js/src/replica/command-runtime/lib/util.ts @@ -54,8 +54,14 @@ export function sameSurface( return ( left.kind === 'role' || (right.kind === 'application' && - left.roles.length === right.roles.length && - left.roles.every((role, index) => role === right.roles[index])) + left.eligible_roles.length === right.eligible_roles.length && + left.eligible_roles.every( + (role, index) => role === right.eligible_roles[index] + ) && + left.schema_roles.length === right.schema_roles.length && + left.schema_roles.every( + (role, index) => role === right.schema_roles[index] + )) ); } @@ -65,7 +71,8 @@ export function cloneSurface(surface: ReplicaClientSurface): ReplicaClientSurfac : Object.freeze({ kind: 'application', name: surface.name, - roles: Object.freeze([...surface.roles]) + eligible_roles: Object.freeze([...surface.eligible_roles]), + schema_roles: Object.freeze([...surface.schema_roles]) }); } diff --git a/js/src/replica/command-runtime/symbols.ts b/js/src/replica/command-runtime/symbols.ts index ed2d8c2b..f3bbee6c 100644 --- a/js/src/replica/command-runtime/symbols.ts +++ b/js/src/replica/command-runtime/symbols.ts @@ -38,3 +38,12 @@ export const replicaCommandProjectionDelta = Symbol( export const replicaCommandProjectedLifecycle = Symbol( 'distributed.replica.command-projected-lifecycle' ); +/** + * Package-private live-record field read for pure reduce optimism. + * Returns undefined when the record is not present (fail closed). + * + * @internal + */ +export const replicaCommandReadRecord = Symbol( + 'distributed.replica.command-read-record' +); diff --git a/js/src/replica/command-runtime/types.ts b/js/src/replica/command-runtime/types.ts index dd753369..bafe5993 100644 --- a/js/src/replica/command-runtime/types.ts +++ b/js/src/replica/command-runtime/types.ts @@ -23,11 +23,13 @@ import type { ReplicaResultEnvelope, ReplicaValue } from '../types.js'; +import type { ReplicaPureFunction } from '../projection-delta/index.js'; import { replicaCommandAuthority, replicaCommandDirectProjection, replicaCommandProjectionDelta, replicaCommandProjectedLifecycle, + replicaCommandReadRecord, replicaResultObservation } from './symbols.js'; @@ -79,6 +81,10 @@ export type ReplicaCommandAuthorityHost = DistributedReplica & { update: (writer: ReplicaOptimisticWriter) => void, semanticChanges: readonly ReplicaIndexSemanticChange[] ) => boolean; + readonly [replicaCommandReadRecord]?: ( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ) => Readonly> | undefined; }; export type ReplicaCommandTransportRequest = Readonly<{ @@ -314,6 +320,11 @@ export type ReplicaCommandRuntimeOptions = Readonly<{ status?: ReplicaCommandStatusArtifact; /** Optional shared replica diagnostics sink used only for static artifact inspection. */ diagnostics?: ReplicaDiagnosticsSink; + /** + * Named pure functions referenced by `artifact.projection.pureReduces`. + * Generated clients ship their inventory; apps do not invent board sims ad hoc. + */ + pureFunctions?: Readonly>; }>; export interface ReplicaCommandRuntime< diff --git a/js/src/replica/commands/clone.ts b/js/src/replica/commands/clone.ts index 1d063674..f6d1eb91 100644 --- a/js/src/replica/commands/clone.ts +++ b/js/src/replica/commands/clone.ts @@ -31,7 +31,8 @@ export function cloneClientSurface(surface: ReplicaClientSurface): ReplicaClient : Object.freeze({ kind: 'application' as const, name: surface.name, - roles: Object.freeze([...surface.roles]) + eligible_roles: Object.freeze([...surface.eligible_roles]), + schema_roles: Object.freeze([...surface.schema_roles]) }); } @@ -316,4 +317,3 @@ export function generateDefault( artifactInvalid(`${path}.generator`); } } - diff --git a/js/src/replica/commands/types.ts b/js/src/replica/commands/types.ts index d1cde4be..b529bf64 100644 --- a/js/src/replica/commands/types.ts +++ b/js/src/replica/commands/types.ts @@ -156,7 +156,7 @@ export type ReplicaCommandRevalidationPlan = { }; /** - * Framework-neutral executable command descriptor emitted by `dctl client`. + * Framework-neutral executable command descriptor emitted by `distributed client`. * * The compiler has already validated the Rust-owned declaration. Runtime * validation remains fail-closed so a stale or hand-edited artifact cannot diff --git a/js/src/replica/commands/validate.ts b/js/src/replica/commands/validate.ts index f782b8ca..add43376 100644 --- a/js/src/replica/commands/validate.ts +++ b/js/src/replica/commands/validate.ts @@ -123,16 +123,37 @@ export function validateClientSurface( if (surface.kind === 'role') return; if ( surface.kind !== 'application' || - !Array.isArray(surface.roles) || - surface.roles.length === 0 + !Array.isArray(surface.eligible_roles) || + !Array.isArray(surface.schema_roles) || + surface.eligible_roles.length === 0 || + surface.schema_roles.length === 0 ) { artifactInvalid(path); } - const roles = new Set(); - for (let index = 0; index < surface.roles.length; index += 1) { - const role = requiredString(surface.roles[index], `${path}.roles[${index}]`); - if (roles.has(role)) artifactInvalid(`${path}.roles[${index}]`); - roles.add(role); + const eligibleRoles = new Set(); + for (let index = 0; index < surface.eligible_roles.length; index += 1) { + const role = requiredString( + surface.eligible_roles[index], + `${path}.eligible_roles[${index}]` + ); + if (eligibleRoles.has(role)) artifactInvalid(`${path}.eligible_roles[${index}]`); + if (index > 0 && surface.eligible_roles[index - 1]! >= role) { + artifactInvalid(`${path}.eligible_roles[${index}]`); + } + eligibleRoles.add(role); + } + const schemaRoles = new Set(); + for (let index = 0; index < surface.schema_roles.length; index += 1) { + const role = requiredString( + surface.schema_roles[index], + `${path}.schema_roles[${index}]` + ); + if (schemaRoles.has(role)) artifactInvalid(`${path}.schema_roles[${index}]`); + if (index > 0 && surface.schema_roles[index - 1]! >= role) { + artifactInvalid(`${path}.schema_roles[${index}]`); + } + if (!eligibleRoles.has(role)) artifactInvalid(`${path}.schema_roles[${index}]`); + schemaRoles.add(role); } } diff --git a/js/src/replica/distributed-replica/helpers.ts b/js/src/replica/distributed-replica/helpers.ts index 4f0b58b4..e67e19ce 100644 --- a/js/src/replica/distributed-replica/helpers.ts +++ b/js/src/replica/distributed-replica/helpers.ts @@ -501,7 +501,10 @@ export function replicaClientRequestExtensions< : Object.freeze({ kind: 'application' as const, name: protocol.surface.name, - roles: Object.freeze([...protocol.surface.roles]) + eligible_roles: Object.freeze([ + ...protocol.surface.eligible_roles + ]), + schema_roles: Object.freeze([...protocol.surface.schema_roles]) }); return Object.freeze({ extensions: Object.freeze({ @@ -629,17 +632,34 @@ export function validatedSurfaceIdentity( } if ( value.kind !== 'application' || - !Array.isArray(value.roles) || - value.roles.length === 0 || - value.roles.some( + !Array.isArray(value.eligible_roles) || + !Array.isArray(value.schema_roles) || + value.eligible_roles.length === 0 || + value.schema_roles.length === 0 || + value.eligible_roles.some( (role) => typeof role !== 'string' || role.length === 0 ) || - new Set(value.roles).size !== value.roles.length || - [...value.roles].sort().some((role, index) => role !== value.roles[index]) + value.schema_roles.some( + (role) => typeof role !== 'string' || role.length === 0 + ) || + new Set(value.eligible_roles).size !== value.eligible_roles.length || + new Set(value.schema_roles).size !== value.schema_roles.length || + [...value.eligible_roles].sort().some( + (role, index) => role !== value.eligible_roles[index] + ) || + [...value.schema_roles].sort().some( + (role, index) => role !== value.schema_roles[index] + ) || + value.schema_roles.some((role) => !value.eligible_roles.includes(role)) ) { throw new TypeError('replica artifact client surface is invalid'); } - return JSON.stringify(['application', value.name, value.roles]); + return JSON.stringify([ + 'application', + value.name, + value.eligible_roles, + value.schema_roles + ]); } export function snapshotFrom( diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index ce03991f..0291bc99 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -34,6 +34,7 @@ import { replicaCommandAuthority, replicaCommandDirectProjection, replicaCommandProjectionDelta, + replicaCommandReadRecord, replicaResultObservation, type ReplicaCommandAuthorityRegistration, type ReplicaCommandAuthoritySnapshot, @@ -84,6 +85,7 @@ import type { ReplicaResultEnvelope, ReplicaSnapshot, ReplicaTransport, + ReplicaValue, ReplicaWatch, ReplicaWriteSource, WatchReplicaOptions @@ -1644,6 +1646,22 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { }); } + /** + * Package-private live fields for pure-reduce optimism. + * @internal + */ + [replicaCommandReadRecord]( + model: ReplicaModelArtifact, + identity: ReplicaIdentity + ): Readonly> | undefined { + const key = replicaRecordKey(model, identity); + return this.#engine.read((reader) => { + const record = reader.record(key); + if (!record) return undefined; + return Object.freeze({ ...record.fields }); + }); + } + inspectIndex(target: ReplicaIndexTarget): ReplicaIndexInspection | undefined { const key = indexKeyFromTarget(target); return this.#engine.read((reader) => { diff --git a/js/src/replica/operation-binding.ts b/js/src/replica/operation-binding.ts index a7ec9f87..243a77c7 100644 --- a/js/src/replica/operation-binding.ts +++ b/js/src/replica/operation-binding.ts @@ -67,17 +67,34 @@ function validateReplicaSurfaceIdentity(value: ReplicaClientSurface): string { } if ( value.kind !== 'application' || - !Array.isArray(value.roles) || - value.roles.length === 0 || - value.roles.some( + !Array.isArray(value.eligible_roles) || + !Array.isArray(value.schema_roles) || + value.eligible_roles.length === 0 || + value.schema_roles.length === 0 || + value.eligible_roles.some( (role) => typeof role !== 'string' || role.length === 0 ) || - new Set(value.roles).size !== value.roles.length || - [...value.roles].sort().some((role, index) => role !== value.roles[index]) + value.schema_roles.some( + (role) => typeof role !== 'string' || role.length === 0 + ) || + new Set(value.eligible_roles).size !== value.eligible_roles.length || + new Set(value.schema_roles).size !== value.schema_roles.length || + [...value.eligible_roles].sort().some( + (role, index) => role !== value.eligible_roles[index] + ) || + [...value.schema_roles].sort().some( + (role, index) => role !== value.schema_roles[index] + ) || + value.schema_roles.some((role) => !value.eligible_roles.includes(role)) ) { throw new TypeError('replica artifact client surface is invalid'); } - return JSON.stringify(['application', value.name, value.roles]); + return JSON.stringify([ + 'application', + value.name, + value.eligible_roles, + value.schema_roles + ]); } function canonicalTrustedPresetDescriptors( diff --git a/js/src/replica/projection-delta/index.ts b/js/src/replica/projection-delta/index.ts index 73021c42..a5b77fc8 100644 --- a/js/src/replica/projection-delta/index.ts +++ b/js/src/replica/projection-delta/index.ts @@ -25,7 +25,9 @@ export type { ProjectionDeltaRecoveryTarget, ProjectionDeltaScope, ProjectionDeltaValue, + ProjectionPreviewPureReduce, ProjectionPreviewScope, ProjectionPreviewValue, - ReplicaCommandProjection + ReplicaCommandProjection, + ReplicaPureFunction } from './types.js'; diff --git a/js/src/replica/projection-delta/resolve.ts b/js/src/replica/projection-delta/resolve.ts index 2afec93f..de9408c6 100644 --- a/js/src/replica/projection-delta/resolve.ts +++ b/js/src/replica/projection-delta/resolve.ts @@ -26,9 +26,12 @@ export function prepareCommandProjection( const preview = contract.preview.operations.map(({ mutation }) => resolvePreviewMutation(mutation, input, presets) ); + const pure = (contract.pureReduces ?? []).map((reduce) => + resolvePureReduce(reduce, input, presets) + ); return Object.freeze({ contract, - preview: Object.freeze(preview), + preview: Object.freeze([...preview, ...pure]), revalidate: contract.preview.recoveries.length !== 0 }); } catch { @@ -42,6 +45,27 @@ export function prepareCommandProjection( } } +function resolvePureReduce( + reduce: NonNullable[number], + input: unknown, + presets: ReadonlyMap +): PreparedProjectionOperation { + const args: Record = Object.create(null) as Record< + string, + ReplicaValue + >; + for (const { name, value } of reduce.args) { + args[name] = requireValue(resolvePreviewValue(value, input, presets)); + } + return Object.freeze({ + kind: 'reduce_known_record' as const, + fn: reduce.fn, + scope: previewScope(reduce.scope, input, presets), + args: Object.freeze(args), + assign: Object.freeze([...reduce.assign]) + }); +} + export function operationsFromProjectionDelta( scopes: readonly { readonly mutation: diff --git a/js/src/replica/projection-delta/types.ts b/js/src/replica/projection-delta/types.ts index 73a48399..a32a973f 100644 --- a/js/src/replica/projection-delta/types.ts +++ b/js/src/replica/projection-delta/types.ts @@ -12,8 +12,9 @@ export type ProjectionDeltaSurface = | Readonly<{ kind: 'application'; name: string; - roles: readonly string[]; - }>; + eligible_roles: readonly string[]; + schema_roles: readonly string[]; + }>; export type ProjectionDeltaIdentity = Readonly<{ manifest_version: 2; @@ -242,6 +243,29 @@ export type ProjectionCapabilityArm = Readonly<{ mutations: readonly ProjectionCapabilityMutation[]; }>; +/** + * Pure reduce: load the known cache row for `scope`, run a named pure function + * with resolved `args`, and patch `assign` fields from the result. + * + * Fail-closed: missing row or pure failure → no invent (same as patch ifPresent). + */ +export type ProjectionPreviewPureReduce = Readonly<{ + fn: string; + /** App lib-relative module (gen-client); optional at runtime. */ + clientModule?: string; + /** Named export (gen-client); optional at runtime. */ + clientExport?: string; + scope: ProjectionPreviewScope; + args: readonly Readonly<{ + name: string; + value: ProjectionPreviewValue; + }>[]; + /** Fields taken from the pure result and written onto the known record. */ + assign: readonly string[]; + occurrence_ordinal: number; + projection_refs: readonly number[]; +}>; + export type ProjectionPreviewMutation = | Readonly<{ op: 'upsert'; @@ -326,6 +350,11 @@ export type ReplicaCommandProjection = Readonly<{ target: ProjectionPreviewRecoveryTarget; }>[]; }>; + /** + * Optional pure reducers over known cache rows. Expanded at optimistic + * apply time (needs the live record); not ordinary preview expressions. + */ + pureReduces?: readonly ProjectionPreviewPureReduce[]; fallback: 'revalidate'; }>; @@ -358,6 +387,13 @@ export type PreparedProjectionOperation = kind: 'invalidate_relationship'; relationship: string; source: PreparedProjectionScope; + }> + | Readonly<{ + kind: 'reduce_known_record'; + fn: string; + scope: PreparedProjectionScope; + args: Readonly>; + assign: readonly string[]; }>; export type PreparedProjectionScope = Readonly<{ @@ -365,6 +401,12 @@ export type PreparedProjectionScope = Readonly<{ key: readonly Readonly<{ field: string; value: ReplicaValue }>[]; }>; +/** Named pure function: known record + resolved args → fields to patch. */ +export type ReplicaPureFunction = ( + record: Readonly>, + args: Readonly> +) => Readonly> | null; + export type PreparedCommandProjection = Readonly<{ contract: ReplicaCommandProjection; preview: readonly PreparedProjectionOperation[]; diff --git a/js/src/replica/projection-delta/validate.ts b/js/src/replica/projection-delta/validate.ts index d0d1fbbc..f65ef52d 100644 --- a/js/src/replica/projection-delta/validate.ts +++ b/js/src/replica/projection-delta/validate.ts @@ -386,14 +386,14 @@ export function validateCommandProjectionArtifact( 'version', 'deltaWireVersion', 'projectionProgramVersion', - 'operationSemanticsVersion', - 'projections', - 'eventSet', - 'capabilities', - 'preview', + 'operationSemanticsVersion', + 'projections', + 'eventSet', + 'capabilities', + 'preview', 'fallback' ], - [], + ['pureReduces'], path ); if ( @@ -677,6 +677,12 @@ export function validateCommandProjectionArtifact( invalid(`${path}.preview.recoveries`); } } + const pureReduces = parsePureReduces( + projection.pureReduces, + occurrences.length, + projections.length, + `${path}.pureReduces` + ); const parsed = Object.freeze({ version: 2 as const, deltaWireVersion: 1 as const, @@ -694,6 +700,7 @@ export function validateCommandProjectionArtifact( operations: Object.freeze(operations), recoveries: Object.freeze(recoveries) }), + ...(pureReduces.length === 0 ? {} : { pureReduces }), fallback: 'revalidate' as const }); if (encoder.encode(JSON.stringify(parsed)).byteLength > MAX_BODY_BYTES) { @@ -702,6 +709,86 @@ export function validateCommandProjectionArtifact( return parsed; } +function parsePureReduces( + value: unknown, + occurrenceCount: number, + projectionCount: number, + path: string +): readonly import('./types.js').ProjectionPreviewPureReduce[] { + if (value === undefined) return Object.freeze([]); + return Object.freeze( + boundedArray(value, path).map((item, index) => { + const itemPath = `${path}[${index}]`; + const reduce = exactRecord( + item, + [ + 'fn', + 'scope', + 'args', + 'assign', + 'occurrence_ordinal', + 'projection_refs' + ], + ['clientModule', 'clientExport'], + itemPath + ); + const fn = reduce.fn; + if (typeof fn !== 'string' || fn.length === 0 || fn.length > 128) { + invalid(`${itemPath}.fn`); + } + const occurrenceOrdinal = boundedOrdinal( + reduce.occurrence_ordinal, + `${itemPath}.occurrence_ordinal` + ); + if (occurrenceOrdinal >= occurrenceCount) invalid(itemPath); + const assign = boundedArray(reduce.assign, `${itemPath}.assign`).map( + (field, fieldIndex) => { + if (typeof field !== 'string' || field.length === 0) { + invalid(`${itemPath}.assign[${fieldIndex}]`); + } + return field as string; + } + ); + if (assign.length === 0) invalid(`${itemPath}.assign`); + const args = boundedArray(reduce.args, `${itemPath}.args`).map( + (arg, argIndex) => { + const argPath = `${itemPath}.args[${argIndex}]`; + const entry = exactRecord(arg, ['name', 'value'], [], argPath); + if (typeof entry.name !== 'string' || entry.name.length === 0) { + invalid(`${argPath}.name`); + } + return Object.freeze({ + name: entry.name as string, + value: parsePreviewValue(entry.value, `${argPath}.value`, 0) + }); + } + ); + const clientModule = + typeof reduce.clientModule === 'string' + ? (reduce.clientModule as string) + : undefined; + const clientExport = + typeof reduce.clientExport === 'string' + ? (reduce.clientExport as string) + : undefined; + return Object.freeze({ + fn: fn as string, + ...(clientModule === undefined ? {} : { clientModule }), + ...(clientExport === undefined ? {} : { clientExport }), + scope: parsePreviewScope(reduce.scope, `${itemPath}.scope`), + args: Object.freeze(args), + assign: Object.freeze(assign), + occurrence_ordinal: occurrenceOrdinal, + projection_refs: projectionRefs( + reduce.projection_refs, + projectionCount, + `${itemPath}.projection_refs` + ) + }); + }) + ); +} + export function validateProjectionMetadataAuthority( metadata: CommandProjectionMetadata, contract: ReplicaCommandProjection, @@ -723,8 +810,12 @@ export function validateProjectionMetadataAuthority( (identity.surface.kind === 'application' && (authority.surface.kind !== 'application' || compareStringArrays( - identity.surface.roles, - authority.surface.roles + identity.surface.eligible_roles, + authority.surface.eligible_roles + ) !== 0 || + compareStringArrays( + identity.surface.schema_roles, + authority.surface.schema_roles ) !== 0)) || identity.schema_fingerprint !== authority.schemaHash || identity.protocol_fingerprint !== authority.protocolHash || @@ -802,23 +893,39 @@ function parseSurface(value: unknown, path: string): ProjectionDeltaSurface { if (value.kind === 'application') { const application = exactRecord( value, - ['kind', 'name', 'roles'], + ['kind', 'name', 'eligible_roles', 'schema_roles'], [], path ); - const roles = boundedArray(application.roles, `${path}.roles`).map( - (role, index) => identityString(role, `${path}.roles[${index}]`) + const eligibleRoles = boundedArray( + application.eligible_roles, + `${path}.eligible_roles` + ).map( + (role, index) => + identityString(role, `${path}.eligible_roles[${index}]`) + ); + const schemaRoles = boundedArray( + application.schema_roles, + `${path}.schema_roles` + ).map((role, index) => + identityString(role, `${path}.schema_roles[${index}]`) ); - if (roles.length === 0) invalid(`${path}.roles`); + if (eligibleRoles.length === 0) invalid(`${path}.eligible_roles`); + if (schemaRoles.length === 0) invalid(`${path}.schema_roles`); assertStrictOrder( - roles, + eligibleRoles, compareUtf8, - `${path}.roles` + `${path}.eligible_roles` ); + assertStrictOrder(schemaRoles, compareUtf8, `${path}.schema_roles`); + if (schemaRoles.some((role) => !eligibleRoles.includes(role))) { + invalid(`${path}.schema_roles`); + } return Object.freeze({ kind: 'application' as const, name: identityString(application.name, `${path}.name`), - roles: Object.freeze(roles) + eligible_roles: Object.freeze(eligibleRoles), + schema_roles: Object.freeze(schemaRoles) }); } invalid(`${path}.kind`); diff --git a/js/src/replica/types.ts b/js/src/replica/types.ts index c6b00f8c..a82b014d 100644 --- a/js/src/replica/types.ts +++ b/js/src/replica/types.ts @@ -543,7 +543,8 @@ export type ReplicaClientSurface = | { readonly kind: 'application'; readonly name: string; - readonly roles: readonly string[]; + readonly eligible_roles: readonly string[]; + readonly schema_roles: readonly string[]; }; /** Compiler-owned causal artifact. Protocol and variable identity are inseparable. */ diff --git a/js/src/sveltekit/vite.ts b/js/src/sveltekit/vite.ts index f28ca5da..2fc0fdfc 100644 --- a/js/src/sveltekit/vite.ts +++ b/js/src/sveltekit/vite.ts @@ -69,7 +69,7 @@ export type DistributedSvelteKitManifestSource = | string | Readonly<{ /** - * Arguments passed to the configured dctl command. The first value + * Arguments passed to the configured distributed command. The first value * must be `client-manifest`; stdout becomes ephemeral compiler input. */ args: readonly string[]; @@ -78,13 +78,13 @@ export type DistributedSvelteKitManifestSource = export type DistributedSvelteKitClientCompiler = Readonly<{ /** `$distributed` or an explicit elevated entrypoint such as `$distributed/admin`. */ module: string; - /** Existing manifest path, or canonical `dctl client-manifest` argv. */ + /** Existing manifest path, or canonical `distributed client-manifest` argv. */ manifest: DistributedSvelteKitManifestSource; /** Verify exactly one concrete role. Mutually exclusive with `surface`. */ role?: string; /** Verify exactly one Rust-declared application surface. */ surface?: string; - /** GraphQL globs passed verbatim as repeated `dctl client --documents`. */ + /** GraphQL globs passed verbatim as repeated `distributed client --documents`. */ documents: readonly string[]; /** Explicit `OPERATION=/route` fallbacks. */ routes?: readonly string[]; @@ -93,9 +93,9 @@ export type DistributedSvelteKitClientCompiler = Readonly<{ }>; export type DistributedSvelteKitViteOptions = Readonly<{ - /** Project root used for dctl cwd, document globs, and output containment. */ + /** Project root used for distributed cwd, document globs, and output containment. */ cwd?: string; - /** Executable invoked without a shell. Defaults to `dctl`. */ + /** Executable invoked without a shell. Defaults to `distributed`. */ command?: string; /** Prefix argv, e.g. `cargo run ... --`; never interpreted by a shell. */ commandArgs?: readonly string[]; @@ -184,7 +184,7 @@ export async function generateDistributedSvelteKit( await runCompilerOnce(options, 'generate'); } -/** Check every configured surface through canonical `dctl client --check`; never write. */ +/** Check every configured surface through canonical `distributed client --check`; never write. */ export async function checkDistributedSvelteKit( options: DistributedSvelteKitViteOptions ): Promise { @@ -394,7 +394,7 @@ export function distributedSvelteKitAliases( options: Pick ): Readonly> { const integration = resolveIntegration( - { ...options, command: 'dctl', commandArgs: [] }, + { ...options, command: 'distributed', commandArgs: [] }, options.cwd ?? process.cwd() ); validateResolvedPathsSync(integration); @@ -419,7 +419,7 @@ function resolveIntegration( throw new TypeError('distributedSvelteKit requires configuration'); } const cwd = resolve(options.cwd ?? fallbackCwd); - const command = (options.command ?? 'dctl').trim(); + const command = (options.command ?? 'distributed').trim(); if (command.length === 0) { throw new TypeError('Distributed SvelteKit command must not be empty'); } @@ -771,7 +771,7 @@ async function materializeManifest( JSON.parse(result.stdout) as unknown; } catch (error) { throw new Error( - `dctl client-manifest for ${client.module} did not emit valid JSON`, + `distributed client-manifest for ${client.module} did not emit valid JSON`, { cause: error } ); } @@ -795,13 +795,13 @@ async function validateGeneratedEntrypoint( const metadata = await lstat(entry); if (metadata.isSymbolicLink() || !metadata.isFile()) { throw new Error( - `dctl client for ${module} did not emit a regular ${GENERATED_SVELTEKIT_MODULE}` + `distributed client for ${module} did not emit a regular ${GENERATED_SVELTEKIT_MODULE}` ); } const canonical = await realpath(entry); if (!isWithin(root, canonical)) { throw new Error( - `dctl client entrypoint ${canonical} escaped project root ${root}` + `distributed client entrypoint ${canonical} escaped project root ${root}` ); } } diff --git a/js/tests/sveltekit-vite.test.mjs b/js/tests/sveltekit-vite.test.mjs index 9cd36afa..fa09f141 100644 --- a/js/tests/sveltekit-vite.test.mjs +++ b/js/tests/sveltekit-vite.test.mjs @@ -65,7 +65,7 @@ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); mkdirSync(out, { recursive: true }); writeFileSync( out + '/sveltekit.ts', - '/** GENERATED by dctl client. Do not edit. */\nexport const surface = ' + + '/** GENERATED by distributed client. Do not edit. */\nexport const surface = ' + JSON.stringify(surface) + ';\nexport const documents = ' + JSON.stringify(documents) + ';\n' ); @@ -78,7 +78,7 @@ process.stdout.write('generated fake client\n'); async function fixture(t) { const root = await mkdtemp(join(tmpdir(), 'distributed-vite-test-')); - const script = join(root, 'fake-dctl.mjs'); + const script = join(root, 'fake-distributed.mjs'); const log = join(root, 'commands.log'); await writeFile(script, fakeDctlSource, 'utf8'); await writeFile(log, '', 'utf8'); diff --git a/migrations/inventory.json b/migrations/inventory.json new file mode 100644 index 00000000..1580c117 --- /dev/null +++ b/migrations/inventory.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "migrations": [ + { + "version": 1, + "description": "initial", + "sqlite": { + "path": "migrations/sqlite/0001_initial.sql", + "sha256": "9a3f07064df04b3d5ddc8e4895bd682d9afbc864cf9871a5957fc4a81c2d75a1" + }, + "postgres": { + "path": "migrations/postgres/0001_initial.sql", + "sha256": "fe5c1345b8d1fc672561cf66a6a916354be85d0e597a5b50b14f01c7a4895d39" + } + }, + { + "version": 2, + "description": "command ledger", + "sqlite": { + "path": "migrations/sqlite/0002_command_ledger.sql", + "sha256": "91f9267d5326fd0b8d51305b38bbc6a14a39fb22a28ef888d24d3dde1120348f" + }, + "postgres": { + "path": "migrations/postgres/0002_command_ledger.sql", + "sha256": "905e8c31b285544a4a0b5c357c55497774c59112d4cc52176f10d962514db883" + } + }, + { + "version": 3, + "description": "projection protocol", + "sqlite": { + "path": "migrations/sqlite/0003_projection_protocol.sql", + "sha256": "786de26e41b3809e74eb53f209f2d0a03ff1ec11e7de121b1a2cbd7093ffa307" + }, + "postgres": { + "path": "migrations/postgres/0003_projection_protocol.sql", + "sha256": "087bbab7045c598e54a995e612b8734c6fea87029b8951d15a820ae7ee0d3d72" + } + }, + { + "version": 4, + "description": "command ledger atomic state", + "sqlite": { + "path": "migrations/sqlite/0004_command_ledger_atomic_state.sql", + "sha256": "fb9729bbea27d6c450eee84931547dc1528b04bc275ec46441841fc01ca5643e" + }, + "postgres": { + "path": "migrations/postgres/0004_command_ledger_atomic_state.sql", + "sha256": "bc49ca9c58a294b7c5876c9fcde8a14a8b6110594a06c48b37720d320a22d97e" + } + } + ] +} diff --git a/scripts/graphql-skill-dry-run.sh b/scripts/graphql-skill-dry-run.sh index 8fbe43a2..8ab891a5 100755 --- a/scripts/graphql-skill-dry-run.sh +++ b/scripts/graphql-skill-dry-run.sh @@ -28,10 +28,10 @@ grep -q 'src/query/' "$SKILL" grep -q 'with_graphql' "$SKILL" echo "OK: README + skill present and teach query layout" -# 2) Build dctl +# 2) Build distributed cd "$ROOT" cargo build -p distributed_cli --quiet -DCTL="$ROOT/target/debug/dctl" +DCTL="$ROOT/target/debug/distributed" test -x "$DCTL" # 3) Scaffold --query-api (as skill documents) diff --git a/scripts/pre-push-contracts-check.sh b/scripts/pre-push-contracts-check.sh new file mode 100755 index 00000000..b2d94a45 --- /dev/null +++ b/scripts/pre-push-contracts-check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Opt-in pre-push hook template (do not install automatically). +# Install with: +# ln -s ../../scripts/pre-push-contracts-check.sh .git/hooks/pre-push +set -eu +cd "$(git rev-parse --show-toplevel)" +make contracts-check diff --git a/src/application/capability.rs b/src/application/capability.rs new file mode 100644 index 00000000..2a8cad36 --- /dev/null +++ b/src/application/capability.rs @@ -0,0 +1,384 @@ +//! Explained capability closure for a validated deployment plan. +//! +//! Capabilities are logical requirements with originating reasons. Provider +//! selection and environment binding are intentionally deferred to runtime +//! and environment policy (tasks 12 and 14). + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::{canonical_json, sha256_fingerprint}; +use super::manifest::ApplicationManifest; +use super::mount::MountSelector; +use crate::graphql::command_contract::CommandConsistency; + +/// A named logical capability required by one or more mounts. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + EventStore, + LockManager, + CommandLedger, + TransactionalOutbox, + Publisher, + EventSubscription, + InboxCheckpoint, + ReadStore, + ChangeFeed, + IdentityMiddleware, + HttpTransport, + WebsocketTransport, + Metrics, + SchemaLifecycle, + LocalCommandDispatch, + RemoteCommandDispatch, + DirectProjectionTransaction, +} + +impl Capability { + pub const fn as_str(self) -> &'static str { + match self { + Self::EventStore => "event_store", + Self::LockManager => "lock_manager", + Self::CommandLedger => "command_ledger", + Self::TransactionalOutbox => "transactional_outbox", + Self::Publisher => "publisher", + Self::EventSubscription => "event_subscription", + Self::InboxCheckpoint => "inbox_checkpoint", + Self::ReadStore => "read_store", + Self::ChangeFeed => "change_feed", + Self::IdentityMiddleware => "identity_middleware", + Self::HttpTransport => "http_transport", + Self::WebsocketTransport => "websocket_transport", + Self::Metrics => "metrics", + Self::SchemaLifecycle => "schema_lifecycle", + Self::LocalCommandDispatch => "local_command_dispatch", + Self::RemoteCommandDispatch => "remote_command_dispatch", + Self::DirectProjectionTransaction => "direct_projection_transaction", + } + } +} + +/// Why a capability is required. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilityReason { + pub capability: Capability, + /// Originating process id when the requirement is process-local. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + /// Originating mount kind when applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_kind: Option, + /// Originating mount id when applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_id: Option, + /// Human-readable but deterministic explanation. + pub reason: String, +} + +/// One required capability with all contributing reasons. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilityRequirement { + pub capability: Capability, + pub reasons: Vec, +} + +/// Renderer-neutral schema/migration lifecycle requirement. +/// +/// Describes *that* schema lifecycle is needed and which logical owner +/// produced the requirement. It does not choose Job, operator, or mode. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SchemaLifecycleRequirement { + pub required: bool, + /// Single logical owner identity when schema lifecycle is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logical_owner: Option, + pub reasons: Vec, +} + +/// Derive explained capabilities for the mounts selected into one process. +pub fn derive_process_capabilities( + manifest: &ApplicationManifest, + process_id: &str, + mounts: &[MountSelector], + remote_commands: bool, +) -> ApplicationResult<(Vec, SchemaLifecycleRequirement)> { + let mut reasons = Vec::new(); + let mut schema_reasons = Vec::new(); + let mut schema_owner: Option = None; + + let push = |reasons: &mut Vec, + capability: Capability, + mount: Option<&MountSelector>, + reason: String| { + reasons.push(CapabilityReason { + capability, + process_id: Some(process_id.to_string()), + mount_kind: mount.map(|mount| mount.kind_label().to_string()), + mount_id: mount.map(|mount| mount.id().to_string()), + reason, + }); + }; + + for mount in mounts { + match mount { + MountSelector::Command { id } => { + let command = manifest + .commands + .iter() + .find(|command| command.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "command", + identity: id.clone(), + })?; + if remote_commands { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!("command `{id}` is dispatched remotely"), + ); + } else { + push( + &mut reasons, + Capability::LocalCommandDispatch, + Some(mount), + format!("command `{id}` is executed by a local mount"), + ); + push( + &mut reasons, + Capability::EventStore, + Some(mount), + format!("local command `{id}` requires an event store"), + ); + push( + &mut reasons, + Capability::LockManager, + Some(mount), + format!("local command `{id}` requires aggregate locks"), + ); + push( + &mut reasons, + Capability::CommandLedger, + Some(mount), + format!("local command `{id}` requires command ledger/dedup"), + ); + } + if !command.emits.is_empty() { + push( + &mut reasons, + Capability::TransactionalOutbox, + Some(mount), + format!("command `{id}` emits facts and needs an outbox"), + ); + push( + &mut reasons, + Capability::Publisher, + Some(mount), + format!("command `{id}` publishes outward facts"), + ); + } + if matches!(command.consistency, CommandConsistency::Atomic) { + push( + &mut reasons, + Capability::DirectProjectionTransaction, + Some(mount), + format!( + "atomic command `{id}` requires collocated direct projection" + ), + ); + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("atomic command `{id}` writes read models in-transaction"), + ); + } + schema_reasons.push(format!("command `{id}` implies schema lifecycle")); + if schema_owner.is_none() { + schema_owner = Some(manifest.name.clone()); + } + } + MountSelector::Projector { id } => { + let projection = manifest + .projections + .iter() + .find(|projection| projection.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "projection", + identity: id.clone(), + })?; + if projection.direct { + push( + &mut reasons, + Capability::DirectProjectionTransaction, + Some(mount), + format!("direct projector `{id}` seals in the writer transaction"), + ); + } else { + push( + &mut reasons, + Capability::EventSubscription, + Some(mount), + format!("eventual projector `{id}` subscribes to facts"), + ); + push( + &mut reasons, + Capability::InboxCheckpoint, + Some(mount), + format!("eventual projector `{id}` checkpoints progress"), + ); + } + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("projector `{id}` writes read models"), + ); + push( + &mut reasons, + Capability::ChangeFeed, + Some(mount), + format!("projector `{id}` publishes change notifications"), + ); + schema_reasons.push(format!("projector `{id}` implies schema lifecycle")); + if schema_owner.is_none() { + schema_owner = Some(manifest.name.clone()); + } + } + MountSelector::Surface { id } => { + push( + &mut reasons, + Capability::ReadStore, + Some(mount), + format!("surface `{id}` queries read models"), + ); + push( + &mut reasons, + Capability::IdentityMiddleware, + Some(mount), + format!("surface `{id}` requires principal identity"), + ); + push( + &mut reasons, + Capability::HttpTransport, + Some(mount), + format!("surface `{id}` is served over HTTP"), + ); + // Live-capable surfaces always advertise change-feed need at plan + // level; runtime may no-op when no @live fields are selected. + push( + &mut reasons, + Capability::ChangeFeed, + Some(mount), + format!("surface `{id}` may expose live queries"), + ); + push( + &mut reasons, + Capability::WebsocketTransport, + Some(mount), + format!("surface `{id}` may serve live subscriptions"), + ); + if remote_commands { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!("surface `{id}` dispatches commands remotely"), + ); + } else if mounts.iter().any(|m| matches!(m, MountSelector::Command { .. })) { + push( + &mut reasons, + Capability::LocalCommandDispatch, + Some(mount), + format!("surface `{id}` dispatches to local command mounts"), + ); + } else { + push( + &mut reasons, + Capability::RemoteCommandDispatch, + Some(mount), + format!( + "surface `{id}` has no local command mounts; remote dispatch required" + ), + ); + } + } + MountSelector::Extension { id } => { + push( + &mut reasons, + Capability::Metrics, + Some(mount), + format!("extension `{id}` may require observability hooks"), + ); + } + } + } + + // Always explain metrics when any process exists — readiness/observability. + if !mounts.is_empty() { + push( + &mut reasons, + Capability::Metrics, + None, + format!("process `{process_id}` exposes readiness and metrics"), + ); + } + + reasons.sort(); + reasons.dedup(); + + let mut by_capability = std::collections::BTreeMap::>::new(); + for reason in reasons { + by_capability + .entry(reason.capability) + .or_default() + .push(reason); + } + let requirements = by_capability + .into_iter() + .map(|(capability, mut reasons)| { + reasons.sort(); + CapabilityRequirement { + capability, + reasons, + } + }) + .collect::>(); + + schema_reasons.sort(); + schema_reasons.dedup(); + if schema_reasons.len() > 1 { + // Single logical owner: the application name. Multiple process reasons + // still share that owner. + if schema_owner.as_deref() != Some(manifest.name.as_str()) && schema_owner.is_some() { + return Err(ApplicationError::Collision { + kind: "schema lifecycle owner", + identity: schema_owner.clone().unwrap_or_default(), + reason: format!( + "expected single logical owner `{}`", + manifest.name + ), + }); + } + } + let schema = SchemaLifecycleRequirement { + required: !schema_reasons.is_empty(), + logical_owner: if schema_reasons.is_empty() { + None + } else { + Some(manifest.name.clone()) + }, + reasons: schema_reasons, + }; + + // Fingerprint stability helper (ensures reasons are canonical-serializable). + let _ = sha256_fingerprint(&serde_json::to_vec(&canonical_json( + &serde_json::to_value(&requirements)?, + ))?); + + Ok((requirements, schema)) +} diff --git a/src/application/command.rs b/src/application/command.rs new file mode 100644 index 00000000..55d68476 --- /dev/null +++ b/src/application/command.rs @@ -0,0 +1,801 @@ +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; +use crate::graphql::command_contract::{ + CommandConsistency, CommandOutcome, TypedCommand, TypedCommandContract, +}; +use crate::graphql::{GraphqlInputType, GraphqlTypeDef}; + +/// Serializable GraphQL type field used by a portable command contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandTypeField { + pub name: String, + pub type_name: String, + pub nullable: bool, + pub list: bool, + pub item_nullable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nested: Option>, +} + +/// Serializable GraphQL input/output type used by a portable command contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandTypeSpec { + pub name: String, + pub fields: Vec, +} + +pub type TypeSpec = CommandTypeSpec; + +impl From<&GraphqlTypeDef> for CommandTypeSpec { + fn from(definition: &GraphqlTypeDef) -> Self { + Self { + name: definition.name.clone(), + fields: definition + .fields + .iter() + .map(|field| CommandTypeField { + name: field.name.clone(), + type_name: field.type_name.clone(), + nullable: field.nullable, + list: field.list, + item_nullable: field.item_nullable, + nested: field.nested.as_deref().map(Self::from).map(Box::new), + }) + .collect(), + } + } +} + +/// One exact outward event identity referenced by a command declaration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EventSpec { + pub name: String, + pub version: u64, + pub body_type: String, + pub body_version: u64, + pub body_schema: String, + pub body_fingerprint: String, + pub body_codec: String, + pub body_codec_version: u16, +} + +/// The portable half of one typed command declaration. +/// +/// The effect, default, confirmation, and projection values are copied from +/// the existing typed-command IR. They remain explicit JSON-shaped data; no +/// handler symbol, Rust `TypeId`, closure, or machine path is retained. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandSpec { + pub id: String, + pub field_name: String, + pub roles: Vec, + pub input: CommandTypeSpec, + pub output: CommandTypeSpec, + pub consistency: CommandConsistency, + pub defaults: serde_json::Value, + pub effects: serde_json::Value, + pub emits: Vec, + pub applies: serde_json::Value, + pub projection_contract: serde_json::Value, + /// Declaration-owned projector/model/key confirmations. These are the + /// portable proof inputs; topology pointers and schemas are intentionally + /// reduced to their canonical identities by `canonical_value()`. + #[serde(default)] + pub confirmations: Vec, + /// Canonical direct-projection proof material, if the outcome is Atomic. + /// The erased Rust type identity is never copied into this value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_projection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projected_model: Option, + pub fingerprint: String, +} + +/// One review-visible declaration containing its portable spec and, when the +/// runtime feature is present, the derived executable mount. Keeping these +/// values together prevents parallel `commands`/`mounts` inventories. +pub struct CommandDefinition { + spec: CommandSpec, + /// The exact typed declaration that produced `spec`. This is retained for + /// contract-only composition so Surface authorization can bind from the + /// declaration-owned GraphQL shapes and effects without reconstructing a + /// lossy command from public JSON. + typed_contract: Option, + mount: Option, +} + +impl Clone for CommandDefinition { + fn clone(&self) -> Self { + Self { + spec: self.spec.clone(), + typed_contract: self.typed_contract.clone(), + mount: self.mount.clone(), + } + } +} + +impl std::fmt::Debug for CommandDefinition { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommandDefinition") + .field("command", &self.spec.id) + .field("typed_contract", &self.typed_contract.is_some()) + .field("runtime_mount", &self.mount.is_some()) + .finish() + } +} + +impl CommandDefinition { + pub fn contract(spec: CommandSpec) -> Self { + Self { + spec, + typed_contract: None, + mount: None, + } + } + + pub fn with_mount(spec: CommandSpec, mount: CommandMount) -> ApplicationResult { + if spec.id != mount.spec().id || spec.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: spec.id, + reason: "command definition and executable mount do not share one spec identity" + .into(), + }); + } + Ok(Self { + spec, + typed_contract: None, + mount: Some(mount), + }) + } + + /// Retain one exact typed declaration beside its portable spec and + /// optional executable mount. The typed contract is never serialized; it + /// exists so framework-owned Surface compilation can consume the original + /// declaration before role/application authorization selection. + pub fn from_typed_command( + command: TypedCommand, + mount: Option, + ) -> ApplicationResult + where + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, + { + let (_, typed_contract) = command.into_parts(); + let spec = CommandSpec::from_contract(&typed_contract)?; + if let Some(mount) = &mount { + validate_mount_spec(&spec, mount)?; + } + Ok(Self { + spec, + typed_contract: Some(typed_contract), + mount, + }) + } + + pub fn spec(&self) -> &CommandSpec { + &self.spec + } + + pub fn mount(&self) -> Option<&CommandMount> { + self.mount.as_ref() + } + + pub(crate) fn typed_contract(&self) -> Option<&TypedCommandContract> { + self.typed_contract.as_ref() + } +} + +fn validate_mount_spec(spec: &CommandSpec, mount: &CommandMount) -> ApplicationResult<()> { + if spec.id != mount.spec().id || spec.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: spec.id.clone(), + reason: "command definition and executable mount do not share one spec identity" + .into(), + }); + } + Ok(()) +} + +impl CommandSpec { + /// Construct a command spec from already-portable pieces. + pub fn try_new( + id: impl Into, + field_name: impl Into, + input: CommandTypeSpec, + output: CommandTypeSpec, + consistency: CommandConsistency, + ) -> ApplicationResult { + let id = LogicalId::try_new("command", id)?.into_string(); + let field_name = field_name.into(); + if field_name.is_empty() { + return Err(ApplicationError::InvalidSpec( + "command field name must not be empty".into(), + )); + } + let mut spec = Self { + id, + field_name, + roles: Vec::new(), + input, + output, + consistency, + defaults: serde_json::Value::Array(Vec::new()), + effects: serde_json::Value::Null, + emits: Vec::new(), + applies: serde_json::Value::Array(Vec::new()), + projection_contract: serde_json::Value::Null, + confirmations: Vec::new(), + direct_projection: None, + projected_model: None, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + spec.validate()?; + Ok(spec) + } + + /// Attach Atomic direct-projection proof material and recompute the fingerprint. + pub fn with_direct_projection( + mut self, + projected_model: impl Into, + proof: serde_json::Value, + ) -> ApplicationResult { + self.consistency = CommandConsistency::Atomic; + self.projected_model = Some(LogicalId::try_new("projected model", projected_model)?.into_string()); + self.direct_projection = Some(proof); + self.refresh_fingerprint()?; + self.validate()?; + Ok(self) + } + + /// Build a portable spec from the framework's existing typed declaration. + pub fn from_typed_command(command: &TypedCommand) -> ApplicationResult + where + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, + { + let (_, contract) = command.clone().into_parts(); + Self::from_contract(&contract) + } + + pub(crate) fn from_contract( + contract: &crate::graphql::command_contract::TypedCommandContract, + ) -> ApplicationResult { + let projection_contract = serde_json::to_value(&contract.projections)?; + let applies = serde_json::to_value(&contract.projections.previews)?; + let confirmations = contract + .confirmations + .iter() + .map(crate::graphql::command_contract::CommandProjectionConfirmation::canonical_value) + .collect(); + let direct_projection = contract + .direct_projection + .as_ref() + .map(crate::graphql::command_contract::CommandDirectProjectionTarget::canonical_value); + let mut emits: Vec = contract + .projections + .selectors + .iter() + .map(|selector| EventSpec { + name: selector.event_name().to_owned(), + version: selector.event_version(), + body_type: selector.body_type_name().to_owned(), + body_version: selector.body_version(), + body_schema: selector.body_schema().to_owned(), + body_fingerprint: selector.body_fingerprint().to_owned(), + body_codec: selector.body_codec().to_owned(), + body_codec_version: selector.body_codec_version(), + }) + .collect(); + emits.sort_by(|left, right| { + (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) + }); + let mut roles = contract.roles.clone(); + roles.sort(); + roles.dedup(); + let mut spec = Self { + id: LogicalId::try_new("command", contract.name.clone())?.into_string(), + field_name: contract.field_name.clone(), + roles, + input: CommandTypeSpec::from(&contract.input), + output: CommandTypeSpec::from(&contract.output), + consistency: contract.consistency, + defaults: serde_json::to_value(&contract.input_defaults)?, + effects: serde_json::to_value(&contract.effects)?, + emits, + applies, + projection_contract, + confirmations, + direct_projection, + projected_model: contract + .projected_model + .as_ref() + .map(|projected| projected.model.clone()), + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + spec.validate()?; + Ok(spec) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } + + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + pub fn identity(&self) -> &str { + &self.id + } + + /// Validate the portable identity and schema surface without touching a + /// handler or any runtime state. + pub fn validate(&self) -> ApplicationResult<()> { + LogicalId::try_new("command", self.id.clone())?; + validate_text("command field", &self.field_name)?; + validate_type("command input", &self.input)?; + validate_type("command output", &self.output)?; + for role in &self.roles { + LogicalId::try_new("command role", role.clone())?; + } + if self.roles.windows(2).any(|roles| roles[0] >= roles[1]) { + return Err(ApplicationError::NonCanonical("command role ordering")); + } + let mut event_names = BTreeSet::new(); + for event in &self.emits { + if !event_names.insert(event.name.clone()) { + return Err(ApplicationError::Duplicate { + kind: "command event", + identity: event.name.clone(), + }); + } + LogicalId::try_new("event", event.name.clone())?; + if event.version == 0 || event.body_version == 0 || event.body_codec_version == 0 { + return Err(ApplicationError::InvalidSpec(format!( + "event `{}` versions must be non-zero", + event.name + ))); + } + validate_text("event body type", &event.body_type)?; + validate_text("event body schema", &event.body_schema)?; + validate_sha256("event body fingerprint", &event.body_fingerprint)?; + validate_text("event body codec", &event.body_codec)?; + } + validate_json_contract("command defaults", &self.defaults)?; + validate_json_contract("command effects", &self.effects)?; + validate_json_contract("command applies", &self.applies)?; + validate_json_contract("command projection contract", &self.projection_contract)?; + for confirmation in &self.confirmations { + validate_json_contract("command confirmation", confirmation)?; + } + if let Some(direct) = &self.direct_projection { + validate_json_contract("command direct projection", direct)?; + } + if let Some(model) = &self.projected_model { + LogicalId::try_new("model", model.clone())?; + } + Ok(()) + } + + pub(crate) fn validate_fingerprint(&self) -> ApplicationResult<()> { + if self.fingerprint.is_empty() { + return Err(ApplicationError::NonCanonical("command fingerprint")); + } + if sha256_fingerprint(&self.canonical_bytes()?) != self.fingerprint { + return Err(ApplicationError::NonCanonical("command fingerprint")); + } + Ok(()) + } + + pub(crate) fn refresh_fingerprint(&mut self) -> ApplicationResult<()> { + let bytes = self.canonical_bytes()?; + self.fingerprint = sha256_fingerprint(&bytes); + Ok(()) + } +} + +fn validate_type(kind: &'static str, definition: &CommandTypeSpec) -> ApplicationResult<()> { + validate_type_at_depth(kind, definition, 0) +} + +fn validate_type_at_depth( + kind: &'static str, + definition: &CommandTypeSpec, + depth: usize, +) -> ApplicationResult<()> { + if depth > super::manifest::MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} nesting exceeds {}", + super::manifest::MAX_MANIFEST_JSON_DEPTH + ))); + } + if definition.fields.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "command type fields count exceeds {}", + super::manifest::MAX_MANIFEST_COLLECTION_ITEMS + ))); + } + validate_text(kind, &definition.name)?; + let mut field_names = BTreeSet::new(); + for field in &definition.fields { + if !field_names.insert(field.name.clone()) { + return Err(ApplicationError::Duplicate { + kind: "command type field", + identity: field.name.clone(), + }); + } + validate_text("command field", &field.name)?; + validate_text("command field type", &field.type_name)?; + if let Some(nested) = &field.nested { + validate_type_at_depth(kind, nested, depth + 1)?; + } + } + Ok(()) +} + +fn validate_sha256(kind: &'static str, value: &str) -> ApplicationResult<()> { + validate_text(kind, value)?; + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + } + Ok(()) +} + +fn validate_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidIdentity { + kind, + value: value.into(), + reason: "must be a non-empty portable value", + }); + } + Ok(()) +} + +fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> ApplicationResult<()> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > super::manifest::MAX_MANIFEST_JSON_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds {} JSON bytes", + super::manifest::MAX_MANIFEST_JSON_BYTES + ))); + } + fn walk( + kind: &'static str, + value: &serde_json::Value, + depth: usize, + ) -> ApplicationResult<()> { + if depth > super::manifest::MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds JSON depth {}", + super::manifest::MAX_MANIFEST_JSON_DEPTH + ))); + } + match value { + serde_json::Value::String(value) => { + if value.len() > super::manifest::MAX_MANIFEST_STRING_BYTES || value.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL string material" + ))); + } + } + serde_json::Value::Array(values) => { + if values.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains too many values" + ))); + } + for value in values { + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Object(fields) => { + if fields.len() > super::manifest::MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains too many object fields" + ))); + } + for (key, value) in fields { + if key.len() > super::manifest::MAX_MANIFEST_STRING_BYTES + || key.contains('\0') + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL object-key material" + ))); + } + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } + Ok(()) + } + walk(kind, value, 0) +} + +/// Executable command material retained only at the heterogeneous runtime +/// boundary. Its handler is intentionally absent from serialization. The +/// boundary is deliberately a callable request/response trait rather than an +/// `Any` value: a runtime can invoke it without knowing the concrete function +/// item type or attempting a downcast. +pub type CommandMountFuture<'a> = Pin< + Box> + + Send + + 'a>, +>; + +pub trait CommandMountHandler: Send + Sync { + fn call(&self, request: crate::microsvc::CommandRequest) -> CommandMountFuture<'_>; +} + +/// Explicit registration seam used by service/runtime adapters. A mount is +/// not executable merely because it contains a value; an adapter must accept +/// it into its registry before it can be dispatched. +pub trait CommandMountRegistrar { + fn register_command_mount(&mut self, mount: CommandMount) -> Result<(), crate::microsvc::HandlerError>; +} + +/// Invocation context for the heterogeneous runtime boundary. The ordinary +/// transport variant is deliberately unable to execute a typed causal mount; +/// only the authenticated variant carries the framework-issued principal and +/// bearer-scoped command identity needed by the existing causal ledger path. +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountInvocation { + Transport, + Authenticated { + command_id: String, + session: crate::microsvc::Session, + principal: crate::graphql::identity::VerifiedPrincipal, + }, +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountInvocation { + Transport, +} + +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionResult { + Transport(crate::microsvc::CommandResponse), + Causal(crate::microsvc::CausalDispatchResult), +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionResult { + Transport(crate::microsvc::CommandResponse), +} + +#[cfg(feature = "graphql")] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionError { + Handler(crate::microsvc::HandlerError), + Causal(crate::microsvc::CausalDispatchError), +} + +#[cfg(not(feature = "graphql"))] +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum CommandMountExecutionError { + Handler(crate::microsvc::HandlerError), +} + +#[allow(dead_code)] +pub(crate) type CommandMountExecutionFuture<'a> = Pin< + Box< + dyn Future< + Output = Result, + > + Send + + 'a, + >, +>; + +/// Runtime adapter for mounts whose authorization and causal commit protocol +/// lives in a service/router. Adapters receive the same mount spec and must +/// route through the existing `CommandRequest`/`CommandResponse` boundary; +/// authenticated typed mounts additionally enter the existing causal receipt +/// and projection-proof protocol. +#[allow(dead_code)] +pub(crate) trait CommandMountExecution: Send + Sync { + fn invoke_mount<'a>( + &'a self, + mount: &'a CommandMount, + request: crate::microsvc::CommandRequest, + invocation: CommandMountInvocation, + ) -> CommandMountExecutionFuture<'a>; +} + +struct RequestCommandMountHandler(H); + +impl CommandMountHandler for RequestCommandMountHandler +where + H: Fn(crate::microsvc::CommandRequest) -> F + Send + Sync, + F: Future> + + Send + + 'static, +{ + fn call(&self, request: crate::microsvc::CommandRequest) -> CommandMountFuture<'_> { + Box::pin((self.0)(request)) + } +} + +pub struct CommandMount { + spec: CommandSpec, + handler: Option>, + typed_route: Option, +} + +impl Clone for CommandMount { + fn clone(&self) -> Self { + Self { + spec: self.spec.clone(), + handler: self.handler.clone(), + typed_route: self.typed_route.clone(), + } + } +} + +impl std::fmt::Debug for CommandMount { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommandMount") + .field("command", &self.spec.id) + .field("callable", &self.handler.is_some()) + .field("typed_route", &self.typed_route) + .finish() + } +} + +impl CommandMount { + /// Create a contract-only mount with no executable handler. + pub fn contract(spec: CommandSpec) -> Self { + Self { + spec, + handler: None, + typed_route: None, + } + } + + /// Erase one executable handler without changing the portable spec. + pub fn from_handler(spec: CommandSpec, handler: H) -> Self + where + H: CommandMountHandler + 'static, + { + Self { + spec, + handler: Some(Arc::new(handler)), + typed_route: None, + } + } + + /// Adapt an owned request handler to the type-erased mount boundary. + /// Request and response values remain the existing transport types and + /// failures retain the framework's typed [`HandlerError`]. + pub fn from_request_handler(spec: CommandSpec, handler: H) -> Self + where + H: Fn(crate::microsvc::CommandRequest) -> F + Send + Sync + 'static, + F: Future< + Output = Result, + > + Send + + 'static, + { + Self::from_handler(spec, RequestCommandMountHandler(handler)) + } + + /// Create the runtime-facing registration token for a typed causal route. + /// The token deliberately contains no fake handler closure: execution is + /// owned by the registered typed route and can only enter through the + /// authenticated causal protocol. + pub fn from_typed_route(spec: CommandSpec, route_name: impl Into) -> Self { + Self { + spec, + handler: None, + typed_route: Some(route_name.into()), + } + } + + pub fn spec(&self) -> &CommandSpec { + &self.spec + } + + pub(crate) fn typed_route_name(&self) -> Option<&str> { + self.typed_route.as_deref() + } + + /// Invoke the erased handler. Contract-only mounts fail closed with a + /// typed authorization error instead of pretending that a mount is + /// executable. + pub fn invoke(&self, request: &crate::microsvc::CommandRequest) -> CommandMountFuture<'_> { + match &self.handler { + Some(handler) => handler.call(request.clone()), + None => Box::pin(async { + Err(crate::microsvc::HandlerError::Unauthorized( + "command mount has no runtime handler".into(), + )) + }), + } + } + + /// Invoke through a registered runtime adapter. Typed mounts must receive + /// an authenticated invocation context; the adapter then enters the + /// existing causal route, preserving authorization, receipts, and + /// projection proofs. A transport-only invocation remains fail-closed. + #[allow(dead_code)] + pub(crate) fn invoke_with<'a, E: CommandMountExecution>( + &'a self, + executor: &'a E, + request: &crate::microsvc::CommandRequest, + invocation: CommandMountInvocation, + ) -> CommandMountExecutionFuture<'a> { + executor.invoke_mount(self, request.clone(), invocation) + } + + /// Register this mount with an explicit runtime adapter. + pub fn register_with( + &self, + registrar: &mut R, + ) -> Result<(), crate::microsvc::HandlerError> { + registrar.register_command_mount(self.clone()) + } +} + +impl TypedCommand +where + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, +{ + /// Compile the exact declaration into its portable, serializable spec. + pub fn spec(&self) -> ApplicationResult { + CommandSpec::from_typed_command(self) + } +} diff --git a/src/application/error.rs b/src/application/error.rs new file mode 100644 index 00000000..13ce739e --- /dev/null +++ b/src/application/error.rs @@ -0,0 +1,78 @@ +use std::fmt; + +/// Errors raised while compiling portable application contracts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplicationError { + InvalidIdentity { + kind: &'static str, + value: String, + reason: &'static str, + }, + Duplicate { + kind: &'static str, + identity: String, + }, + Collision { + kind: &'static str, + identity: String, + reason: String, + }, + Missing { + kind: &'static str, + identity: String, + }, + InvalidSpec(String), + UnsupportedVersion { + expected: u32, + actual: u32, + }, + NonCanonical(&'static str), + Canonical(String), +} + +pub type ApplicationResult = Result; + +impl fmt::Display for ApplicationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIdentity { + kind, + value, + reason, + } => write!(formatter, "invalid {kind} identity `{value}`: {reason}"), + Self::Duplicate { kind, identity } => { + write!(formatter, "duplicate {kind} identity `{identity}`") + } + Self::Collision { + kind, + identity, + reason, + } => write!( + formatter, + "colliding {kind} identity `{identity}`: {reason}" + ), + Self::Missing { kind, identity } => { + write!(formatter, "missing {kind} identity `{identity}`") + } + Self::InvalidSpec(reason) => { + write!(formatter, "invalid application specification: {reason}") + } + Self::UnsupportedVersion { expected, actual } => write!( + formatter, + "unsupported application manifest schema version {actual}; expected {expected}" + ), + Self::NonCanonical(kind) => write!(formatter, "non-canonical {kind} bytes"), + Self::Canonical(reason) => { + write!(formatter, "canonical application artifact error: {reason}") + } + } + } +} + +impl std::error::Error for ApplicationError {} + +impl From for ApplicationError { + fn from(error: serde_json::Error) -> Self { + Self::Canonical(error.to_string()) + } +} diff --git a/src/application/identity.rs b/src/application/identity.rs new file mode 100644 index 00000000..de5662bc --- /dev/null +++ b/src/application/identity.rs @@ -0,0 +1,97 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::error::{ApplicationError, ApplicationResult}; + +/// A portable logical identity. +/// +/// Logical identities intentionally reject path separators, whitespace, +/// control characters, and environment-like syntax. They are names in a +/// manifest, never filesystem paths or runtime endpoints. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct LogicalId(String); + +impl LogicalId { + pub fn try_new(kind: &'static str, value: impl Into) -> ApplicationResult { + let value = value.into(); + if value.is_empty() { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not be empty", + }); + } + if value.trim() != value { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not have leading or trailing whitespace", + }); + } + if value.starts_with('.') || value.ends_with('.') || value.contains("..") { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "must not start, end, or repeat a separator", + }); + } + if !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':') + }) { + return Err(ApplicationError::InvalidIdentity { + kind, + value, + reason: "may contain only ASCII letters, digits, '.', '_', '-', and ':'", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef for LogicalId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for LogicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +/// Recursively sort JSON objects so an IR value has one portable encoding. +pub fn canonical_json(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.iter().map(canonical_json).collect()) + } + serde_json::Value::Object(values) => { + let mut sorted = serde_json::Map::new(); + for (key, value) in values { + sorted.insert(key.clone(), canonical_json(value)); + } + serde_json::Value::Object(sorted) + } + other => other.clone(), + } +} + +/// Compute a domain-separated SHA-256 fingerprint for portable bytes. +pub fn sha256_fingerprint(bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(b"distributed.application-artifact.v1\0"); + digest.update(bytes); + format!("sha256:{:x}", digest.finalize()) +} diff --git a/src/application/manifest.rs b/src/application/manifest.rs new file mode 100644 index 00000000..9e25a761 --- /dev/null +++ b/src/application/manifest.rs @@ -0,0 +1,1817 @@ +use std::collections::{BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::command::CommandSpec; +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; +use super::module::{ModelSpec, Module, ModuleManifest, ProjectionSpec, SurfaceSpec}; + +/// Wire/schema version for the complete logical application manifest. +pub const APPLICATION_MANIFEST_SCHEMA_VERSION: u32 = 1; + +/// Bounds applied before a portable application artifact is accepted. +pub const MAX_APPLICATION_MANIFEST_BYTES: usize = 1024 * 1024; +pub const MAX_MANIFEST_COLLECTION_ITEMS: usize = 4096; +pub const MAX_MANIFEST_STRING_BYTES: usize = 4096; +pub const MAX_MANIFEST_JSON_BYTES: usize = 256 * 1024; +pub const MAX_MANIFEST_JSON_DEPTH: usize = 32; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestFingerprint { + pub logical: String, + pub canonical: String, +} + +/// Portable provenance carried by the artifact envelope. Provenance is part +/// of canonical artifact bytes, but volatile source metadata is deliberately +/// excluded from the logical fingerprint (see [`ApplicationManifest::logical_fingerprint`]). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestProvenance { + pub generator: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, + #[serde(default)] + pub sources: Vec, +} + +impl Default for ManifestProvenance { + fn default() -> Self { + Self { + generator: "distributed.application.compiler.v1".into(), + source_revision: None, + sources: Vec::new(), + } + } +} + +/// A named, versioned extension value in the explicit application +/// declaration. Extensions are data-only and are included in application +/// identity; executable/runtime configuration must use a deployment layer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationExtension { + pub id: String, + pub version: u32, + pub value: serde_json::Value, +} + +impl ApplicationExtension { + pub fn try_new( + id: impl Into, + version: u32, + value: serde_json::Value, + ) -> ApplicationResult { + let id = LogicalId::try_new("application extension", id)?.into_string(); + if version == 0 { + return Err(ApplicationError::InvalidSpec( + "application extension version must be non-zero".into(), + )); + } + let extension = Self { id, version, value }; + validate_json_contract("application extension", &extension.value)?; + Ok(extension) + } +} + +/// The sole complete logical application manifest owner. +/// +/// Physical tables, service endpoints, transports, observability, and +/// executable handlers are intentionally absent. Those belong to named +/// schema/deployment/runtime layers and cannot become portable application +/// identity by accident. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationManifest { + /// This field is required during decoding: canonical input may not omit + /// the explicit schema version and receive a legacy default. + pub schema_version: u32, + pub name: String, + #[serde(default)] + pub modules: Vec, + #[serde(default)] + pub commands: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub projections: Vec, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub surfaces: Vec, + #[serde(default)] + pub required_capabilities: Vec, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub fingerprints: ManifestFingerprint, + #[serde(default)] + pub provenance: ManifestProvenance, +} + +impl ApplicationManifest { + pub fn new(name: impl Into) -> Self { + Self { + schema_version: APPLICATION_MANIFEST_SCHEMA_VERSION, + name: name.into(), + modules: Vec::new(), + commands: Vec::new(), + events: Vec::new(), + projections: Vec::new(), + models: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + extensions: Vec::new(), + fingerprints: ManifestFingerprint::default(), + provenance: ManifestProvenance::default(), + } + } + + /// Compile explicit modules and logical selected surfaces into one + /// manifest owner. A concrete `Surface` must first be compiled into a + /// `SurfaceSpec` by the shared contract compiler. + pub fn try_from_modules( + name: impl Into, + modules: impl IntoIterator, + surfaces: impl IntoIterator, + ) -> ApplicationResult { + let modules = modules.into_iter().collect::>(); + let mut module_manifests = modules + .iter() + .map(|module| module.manifest().clone()) + .collect::>(); + module_manifests.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids( + "module", + module_manifests.iter().map(|module| module.id.clone()), + )?; + + let mut commands = Vec::new(); + let mut events = Vec::new(); + let mut projections = Vec::new(); + let mut models = Vec::new(); + let mut required_capabilities = Vec::new(); + let mut module_surfaces = Vec::new(); + for module in &module_manifests { + commands.extend(module.commands.clone()); + events.extend(module.events.clone()); + projections.extend(module.projections.clone()); + models.extend(module.models.clone()); + required_capabilities.extend(module.required_capabilities.clone()); + module_surfaces.extend(module.surfaces.clone()); + } + let explicit_surfaces = surfaces.into_iter().collect::>(); + let mut surfaces = module_surfaces; + surfaces.extend(explicit_surfaces); + surfaces.sort_by(|left, right| left.id.cmp(&right.id)); + surfaces = dedup_surfaces(surfaces)?; + projections.extend( + surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ); + models.extend( + surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ); + + let mut manifest = Self::new(name); + manifest.modules = module_manifests; + manifest.commands = dedup_commands(commands)?; + manifest.events = dedup_events(events)?; + manifest.projections = dedup_projections(projections)?; + manifest.models = dedup_models(models)?; + manifest.surfaces = surfaces; + manifest.required_capabilities = required_capabilities; + manifest.canonicalize_collections(); + manifest.validate()?; + manifest.refresh_fingerprints()?; + Ok(manifest) + } + + pub fn with_provenance(mut self, provenance: ManifestProvenance) -> Self { + self.provenance = provenance; + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn with_source_revision(mut self, revision: impl Into) -> Self { + self.provenance.source_revision = Some(revision.into()); + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn with_extension(mut self, extension: ApplicationExtension) -> Self { + self.extensions.push(extension); + self.fingerprints = ManifestFingerprint::default(); + self + } + + pub fn module_ids(&self) -> Vec<&str> { + self.modules.iter().map(|module| module.id.as_str()).collect() + } + + /// Return exact deterministic manifest bytes, including the explicit + /// schema version and complete fingerprint material. + pub fn canonical_bytes(&self) -> ApplicationResult> { + // Encoding is also an acceptance boundary: an in-memory attacker may + // not manufacture a byte artifact with empty nested fingerprints and + // rely on the outer manifest fingerprint to make it look complete. + self.validate_inner(true, false)?; + let mut canonical = self.clone(); + canonical.canonicalize_collections(); + canonical.fingerprints = ManifestFingerprint::default(); + let mut logical = canonical.clone(); + logical.provenance = ManifestProvenance::default(); + let logical_bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&logical)?))?; + canonical.fingerprints.logical = sha256_fingerprint(&logical_bytes); + let canonical_without_canonical = + serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + canonical.fingerprints.canonical = sha256_fingerprint(&canonical_without_canonical); + let bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + if bytes.len() > MAX_APPLICATION_MANIFEST_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "application manifest exceeds {MAX_APPLICATION_MANIFEST_BYTES} bytes" + ))); + } + Ok(bytes) + } + + pub fn refresh_fingerprints(&mut self) -> ApplicationResult<()> { + self.fingerprints = ManifestFingerprint::default(); + let bytes = self.canonical_bytes()?; + let canonical: Self = serde_json::from_slice(&bytes) + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + self.fingerprints = canonical.fingerprints; + Ok(()) + } + + pub fn encode(&self) -> ApplicationResult> { + self.canonical_bytes() + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> ApplicationResult { + if bytes.is_empty() || bytes.len() > MAX_APPLICATION_MANIFEST_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "application manifest bytes must be between 1 and {MAX_APPLICATION_MANIFEST_BYTES}" + ))); + } + let manifest: Self = serde_json::from_slice(bytes) + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + if manifest.schema_version != APPLICATION_MANIFEST_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: APPLICATION_MANIFEST_SCHEMA_VERSION, + actual: manifest.schema_version, + }); + } + manifest.validate_inner(true, true)?; + if manifest.canonical_bytes()? != bytes { + return Err(ApplicationError::NonCanonical("application manifest")); + } + Ok(manifest) + } + + pub fn decode(bytes: &[u8]) -> ApplicationResult { + Self::from_canonical_bytes(bytes) + } + + pub fn fingerprint(&self) -> ApplicationResult { + let bytes = self.canonical_bytes()?; + let manifest = Self::from_canonical_bytes(&bytes)?; + Ok(manifest.fingerprints.canonical) + } + + pub fn validate(&self) -> ApplicationResult<()> { + self.validate_inner(true, false) + } + + /// Return the logical identity, excluding generator/source provenance. + /// The canonical artifact fingerprint still changes when those fields do. + pub fn logical_fingerprint(&self) -> ApplicationResult { + let bytes = self.canonical_bytes()?; + let manifest = Self::from_canonical_bytes(&bytes)?; + Ok(manifest.fingerprints.logical) + } + + fn validate_inner( + &self, + require_nested_fingerprints: bool, + require_manifest_fingerprints: bool, + ) -> ApplicationResult<()> { + if self.schema_version != APPLICATION_MANIFEST_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: APPLICATION_MANIFEST_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + LogicalId::try_new("application", self.name.clone())?; + validate_collection_len("modules", self.modules.len())?; + validate_collection_len("commands", self.commands.len())?; + validate_collection_len("events", self.events.len())?; + validate_collection_len("projections", self.projections.len())?; + validate_collection_len("models", self.models.len())?; + validate_collection_len("surfaces", self.surfaces.len())?; + validate_collection_len("extensions", self.extensions.len())?; + validate_unique_ids("module", self.modules.iter().map(|module| module.id.clone()))?; + validate_unique_ids("command", self.commands.iter().map(|command| command.id.clone()))?; + validate_unique_ids("event", self.events.iter().map(|event| event.name.clone()))?; + validate_unique_ids( + "projection", + self.projections.iter().map(|projection| projection.id.clone()), + )?; + validate_unique_ids("model", self.models.iter().map(|model| model.id.clone()))?; + validate_unique_ids("surface", self.surfaces.iter().map(|surface| surface.id.clone()))?; + + let model_ids = self + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + let projection_ids = self + .projections + .iter() + .map(|projection| projection.id.as_str()) + .collect::>(); + for module in &self.modules { + validate_module(module, require_nested_fingerprints)?; + } + for command in &self.commands { + command.validate()?; + validate_fingerprint( + "command", + &command.id, + &command.fingerprint, + command, + require_nested_fingerprints, + )?; + if let Some(model) = &command.projected_model { + require_reference("model", model, &model_ids)?; + } + } + for event in &self.events { + validate_event(event)?; + } + for projection in &self.projections { + validate_projection( + projection, + &model_ids, + &projection_ids, + require_nested_fingerprints, + )?; + } + for model in &self.models { + validate_model(model, &model_ids, require_nested_fingerprints)?; + } + for surface in &self.surfaces { + validate_surface( + surface, + &model_ids, + &projection_ids, + require_nested_fingerprints, + )?; + } + for capability in &self.required_capabilities { + validate_portable_text("capability", capability)?; + } + let mut extension_ids = BTreeSet::new(); + for extension in &self.extensions { + if !extension_ids.insert(extension.id.clone()) { + return Err(ApplicationError::Duplicate { + kind: "application extension", + identity: extension.id.clone(), + }); + } + LogicalId::try_new("application extension", extension.id.clone())?; + if extension.version == 0 { + return Err(ApplicationError::InvalidSpec( + "application extension version must be non-zero".into(), + )); + } + validate_json_contract("application extension", &extension.value)?; + } + validate_portable_text("manifest generator", &self.provenance.generator)?; + if let Some(revision) = &self.provenance.source_revision { + validate_artifact_text("source revision", revision)?; + } + for source in &self.provenance.sources { + validate_artifact_text("manifest source", source)?; + } + + let expected = expected_fingerprints(self)?; + if require_manifest_fingerprints + && (self.fingerprints.logical.is_empty() || self.fingerprints.canonical.is_empty()) + { + return Err(ApplicationError::NonCanonical( + "application manifest fingerprint material", + )); + } + if (!self.fingerprints.logical.is_empty() && self.fingerprints.logical != expected.logical) + || (!self.fingerprints.canonical.is_empty() + && self.fingerprints.canonical != expected.canonical) + { + return Err(ApplicationError::NonCanonical( + "application manifest fingerprint material", + )); + } + validate_manifest_ownership(self)?; + Ok(()) + } + + fn canonicalize_collections(&mut self) { + self.modules.sort_by(|left, right| left.id.cmp(&right.id)); + self.commands.sort_by(|left, right| left.id.cmp(&right.id)); + self.events.sort_by(|left, right| { + (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) + }); + self.projections.sort_by(|left, right| left.id.cmp(&right.id)); + self.models.sort_by(|left, right| left.id.cmp(&right.id)); + self.surfaces.sort_by(|left, right| left.id.cmp(&right.id)); + self.required_capabilities.sort(); + self.required_capabilities.dedup(); + self.extensions.sort_by(|left, right| { + (left.id.as_str(), left.version).cmp(&(right.id.as_str(), right.version)) + }); + self.provenance.sources.sort(); + self.provenance.sources.dedup(); + } +} + +fn expected_fingerprints(manifest: &ApplicationManifest) -> ApplicationResult { + let mut canonical = manifest.clone(); + canonical.canonicalize_collections(); + canonical.fingerprints = ManifestFingerprint::default(); + let mut logical = canonical.clone(); + logical.provenance = ManifestProvenance::default(); + let logical_bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(&logical)?))?; + let logical = sha256_fingerprint(&logical_bytes); + canonical.fingerprints.logical = logical.clone(); + let with_logical = serde_json::to_vec(&canonical_json(&serde_json::to_value(&canonical)?))?; + let canonical_fingerprint = sha256_fingerprint(&with_logical); + Ok(ManifestFingerprint { + logical, + canonical: canonical_fingerprint, + }) +} + +fn validate_module( + module: &ModuleManifest, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("module", module.id.clone())?; + validate_collection_len("module commands", module.commands.len())?; + validate_collection_len("module events", module.events.len())?; + validate_collection_len("module projections", module.projections.len())?; + validate_collection_len("module models", module.models.len())?; + validate_collection_len("module surfaces", module.surfaces.len())?; + validate_collection_len( + "module required capabilities", + module.required_capabilities.len(), + )?; + validate_fingerprint( + "module", + &module.id, + &module.fingerprint, + module, + require_nested_fingerprints, + )?; + validate_unique_ids("module command", module.commands.iter().map(|item| item.id.clone()))?; + validate_unique_ids( + "module projection", + module.projections.iter().map(|item| item.id.clone()), + )?; + validate_unique_ids("module event", module.events.iter().map(|item| item.name.clone()))?; + validate_unique_ids("module model", module.models.iter().map(|item| item.id.clone()))?; + validate_unique_ids("module surface", module.surfaces.iter().map(|item| item.id.clone()))?; + validate_sorted_unique( + "module commands", + &module.commands.iter().map(|item| item.id.clone()).collect::>(), + )?; + validate_sorted_unique( + "module events", + &module.events.iter().map(|item| item.name.clone()).collect::>(), + )?; + validate_sorted_unique( + "module projections", + &module + .projections + .iter() + .map(|item| item.id.clone()) + .collect::>(), + )?; + validate_sorted_unique( + "module models", + &module.models.iter().map(|item| item.id.clone()).collect::>(), + )?; + validate_sorted_unique( + "module surfaces", + &module.surfaces.iter().map(|item| item.id.clone()).collect::>(), + )?; + let module_model_ids = module + .models + .iter() + .map(|model| model.id.as_str()) + .chain(module.surfaces.iter().flat_map(|surface| { + surface.models.iter().map(|model| model.id.as_str()) + })) + .collect::>(); + let module_projection_ids = module + .projections + .iter() + .map(|projection| projection.id.as_str()) + .chain(module.surfaces.iter().flat_map(|surface| { + surface.projections.iter().map(|projection| projection.id.as_str()) + })) + .collect::>(); + for command in &module.commands { + command.validate()?; + validate_fingerprint( + "command", + &command.id, + &command.fingerprint, + command, + require_nested_fingerprints, + )?; + } + let emitted_events = dedup_events( + module + .commands + .iter() + .flat_map(|command| command.emits.iter().cloned()) + .collect(), + )?; + if emitted_events != module.events { + return Err(ApplicationError::Collision { + kind: "event", + identity: module.id.clone(), + reason: "module event inventory is not closed over command declarations".into(), + }); + } + for event in &module.events { + validate_event(event)?; + } + for projection in &module.projections { + validate_projection( + projection, + &module_model_ids, + &module_projection_ids, + require_nested_fingerprints, + )?; + } + for surface in &module.surfaces { + validate_surface( + surface, + &module_model_ids, + &module_projection_ids, + require_nested_fingerprints, + )?; + } + for model in &module.models { + validate_model(model, &module_model_ids, require_nested_fingerprints)?; + } + for capability in &module.required_capabilities { + validate_portable_text("module capability", capability)?; + } + Ok(()) +} + +fn validate_event(event: &super::command::EventSpec) -> ApplicationResult<()> { + LogicalId::try_new("event", event.name.clone())?; + if event.version == 0 || event.body_version == 0 || event.body_codec_version == 0 { + return Err(ApplicationError::InvalidSpec(format!( + "event `{}` versions must be non-zero", + event.name + ))); + } + validate_portable_text("event body type", &event.body_type)?; + validate_portable_text("event body schema", &event.body_schema)?; + validate_sha256_text("event body fingerprint", &event.body_fingerprint)?; + validate_portable_text("event body codec", &event.body_codec)?; + Ok(()) +} + +fn validate_projection( + projection: &ProjectionSpec, + model_ids: &HashSet<&str>, + projection_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("projection", projection.id.clone())?; + validate_fingerprint( + "projection", + &projection.id, + &projection.fingerprint, + projection, + require_nested_fingerprints, + )?; + validate_collection_len("projection facts", projection.facts.len())?; + validate_collection_len("projection models", projection.models.len())?; + validate_collection_len("projection dependencies", projection.dependencies.len())?; + validate_collection_len("modeled projections", projection.modeled.len())?; + validate_sorted_unique("projection facts", &projection.facts)?; + validate_sorted_unique("projection models", &projection.models)?; + validate_sorted_unique("projection dependencies", &projection.dependencies)?; + validate_sorted_unique("modeled projection program IDs", &projection.modeled_programs)?; + for fact in &projection.facts { + validate_portable_text("projection fact", fact)?; + } + for model in &projection.models { + validate_portable_text("projection model", model)?; + require_reference("model", model, model_ids)?; + } + validate_json_contract("projection partition", &projection.partition)?; + let mut modeled_ids = Vec::with_capacity(projection.modeled.len()); + for modeled in &projection.modeled { + validate_json_contract("modeled projection", modeled)?; + let fields = modeled.as_object().ok_or_else(|| { + ApplicationError::InvalidSpec("modeled projection must be an object".into()) + })?; + let program_id = fields + .get("program_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection must retain its program identity".into(), + ) + })?; + validate_portable_text("modeled projection program ID", program_id)?; + modeled_ids.push(program_id.to_owned()); + let output_models = fields + .get("output_models") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection must retain its output model inventory".into(), + ) + })?; + for output_model in output_models { + let output_model = output_model.as_str().ok_or_else(|| { + ApplicationError::InvalidSpec( + "modeled projection output model identities must be strings".into(), + ) + })?; + require_reference("model", output_model, model_ids)?; + } + } + modeled_ids.sort(); + if modeled_ids != projection.modeled_programs { + return Err(ApplicationError::Collision { + kind: "projection", + identity: projection.id.clone(), + reason: "modeled projection program identities are stale or incomplete".into(), + }); + } + if let Some(catalog) = &projection.catalog_fingerprint { + validate_sha256_text("projection catalog fingerprint", catalog)?; + } + for dependency in &projection.dependencies { + if let Some(identity) = dependency.strip_prefix("projection:") { + LogicalId::try_new("projection", identity.to_owned())?; + require_reference("projection", identity, projection_ids)?; + } else { + validate_portable_text("projection dependency", dependency)?; + } + } + Ok(()) +} + +fn validate_model( + model: &ModelSpec, + model_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("model", model.id.clone())?; + validate_fingerprint( + "model", + &model.id, + &model.fingerprint, + model, + require_nested_fingerprints, + )?; + validate_portable_text("model table", &model.table)?; + validate_portable_text("model object", &model.object)?; + validate_collection_len("model fields", model.fields.len())?; + validate_collection_len("model relationships", model.relationships.len())?; + if model.role_limit.is_some_and(|limit| limit == 0) { + return Err(ApplicationError::InvalidSpec(format!( + "model `{}` has a zero role limit", + model.id + ))); + } + validate_row_policy(&model.row_policy)?; + let field_names = model + .fields + .iter() + .map(|field| field.name.clone()) + .collect::>(); + validate_sorted_unique("model fields", &field_names)?; + let relationship_names = model + .relationships + .iter() + .map(|relationship| relationship.name.clone()) + .collect::>(); + validate_sorted_unique("model relationships", &relationship_names)?; + if model.primary_key.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{}` must declare a primary key", + model.id + ))); + } + validate_sorted_unique("model primary key", &model.primary_key)?; + let field_names = field_names.iter().map(String::as_str).collect::>(); + for field in &model.fields { + validate_portable_text("model field", &field.name)?; + validate_portable_text("model field scalar", &field.scalar)?; + } + for key in &model.primary_key { + validate_portable_text("model primary key field", key)?; + if !field_names.contains(key.as_str()) { + return Err(ApplicationError::Missing { + kind: "model field", + identity: key.clone(), + }); + } + } + for relationship in &model.relationships { + validate_portable_text("relationship name", &relationship.name)?; + validate_portable_text("relationship target model", &relationship.target_model)?; + validate_portable_text("relationship target object", &relationship.target_object)?; + if !matches!( + relationship.kind.as_str(), + "hasmany" | "belongsto" | "manytomany" + ) { + return Err(ApplicationError::InvalidSpec(format!( + "relationship `{}` has unknown kind `{}`", + relationship.name, relationship.kind + ))); + } + require_reference("model", &relationship.target_model, model_ids)?; + validate_collection_len("relationship arguments", relationship.arguments.len())?; + let argument_names = relationship + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("relationship arguments", &argument_names)?; + for argument in &relationship.arguments { + validate_surface_argument(argument)?; + } + for dependency in &relationship.dependencies { + validate_portable_text("relationship dependency", dependency)?; + } + validate_sorted_unique("relationship dependencies", &relationship.dependencies)?; + validate_json_contract("relationship keys", &relationship.keys)?; + validate_relationship_keys(&relationship.keys)?; + if let Some(aggregate) = &relationship.aggregate { + validate_portable_text("relationship aggregate", &aggregate.name)?; + validate_portable_text("relationship aggregate type", &aggregate.type_name)?; + validate_collection_len( + "relationship aggregate arguments", + aggregate.arguments.len(), + )?; + let argument_names = aggregate + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("relationship aggregate arguments", &argument_names)?; + for argument in &aggregate.arguments { + validate_surface_argument(argument)?; + } + for dependency in &aggregate.dependencies { + validate_portable_text("relationship aggregate dependency", dependency)?; + } + validate_sorted_unique( + "relationship aggregate dependencies", + &aggregate.dependencies, + )?; + } + } + Ok(()) +} + +fn validate_surface( + surface: &SurfaceSpec, + model_ids: &HashSet<&str>, + projection_ids: &HashSet<&str>, + require_nested_fingerprints: bool, +) -> ApplicationResult<()> { + LogicalId::try_new("surface", surface.id.clone())?; + validate_fingerprint( + "surface", + &surface.id, + &surface.fingerprint, + surface, + require_nested_fingerprints, + )?; + validate_collection_len("surface models", surface.models.len())?; + validate_collection_len("surface roots", surface.roots.len())?; + validate_collection_len("surface commands", surface.commands.len())?; + validate_collection_len("surface projections", surface.projections.len())?; + validate_sorted_unique( + "surface models", + &surface + .models + .iter() + .map(|model| model.id.clone()) + .collect::>(), + )?; + let root_ids = surface + .roots + .iter() + .map(|root| format!("{}:{}", root.operation, root.name)) + .collect::>(); + validate_sorted_unique("surface roots", &root_ids)?; + validate_sorted_unique( + "surface commands", + &surface + .commands + .iter() + .map(|command| command.id.clone()) + .collect::>(), + )?; + validate_sorted_unique( + "surface projections", + &surface + .projections + .iter() + .map(|projection| projection.id.clone()) + .collect::>(), + )?; + validate_json_contract("surface canonical contract", &surface.contract)?; + validate_surface_selection(surface)?; + validate_portable_text("surface dialect", &surface.dialect)?; + if surface.max_limit == 0 || surface.default_limit > surface.max_limit { + return Err(ApplicationError::InvalidSpec(format!( + "surface `{}` has invalid pagination bounds", + surface.id + ))); + } + let surface_models = surface + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + let model_scope = if model_ids.is_empty() { + &surface_models + } else { + model_ids + }; + for model in &surface.models { + validate_model(model, model_scope, require_nested_fingerprints)?; + } + for root in &surface.roots { + if !matches!(root.operation.as_str(), "query" | "subscription") { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has unsupported operation `{}`", + root.name, root.operation + ))); + } + validate_portable_text("surface root operation", &root.operation)?; + validate_portable_text("surface root name", &root.name)?; + validate_portable_text("surface root object", &root.object)?; + if !matches!(root.kind.as_str(), "list" | "by_pk" | "aggregate") { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has unknown kind `{}`", + root.name, root.kind + ))); + } + LogicalId::try_new("surface root model", root.model.clone())?; + require_reference("model", &root.model, model_scope)?; + validate_collection_len("surface root arguments", root.arguments.len())?; + let argument_names = root + .arguments + .iter() + .map(|argument| argument.name.clone()) + .collect::>(); + validate_unique("surface root arguments", &argument_names)?; + for argument in &root.arguments { + validate_surface_argument(argument)?; + } + for dependency in &root.dependencies { + validate_portable_text("surface root dependency", dependency)?; + } + if let Some(max) = root.max_limit { + if max == 0 || root.default_limit.is_some_and(|default| default > max) { + return Err(ApplicationError::InvalidSpec(format!( + "surface root `{}` has invalid pagination bounds", + root.name + ))); + } + } + } + for command in &surface.commands { + LogicalId::try_new("command", command.id.clone())?; + validate_portable_text("surface command field", &command.field_name)?; + validate_roles("surface command role", &command.roles)?; + if let Some(input) = &command.input { + validate_command_type_spec(input)?; + } + if let Some(output) = &command.output { + validate_command_type_spec(output)?; + } + validate_json_contract("surface command defaults", &command.defaults)?; + validate_json_contract("surface command effects", &command.effects)?; + validate_json_contract("surface command confirmations", &command.confirmations)?; + validate_json_contract("surface command projection contract", &command.projection_contract)?; + validate_json_contract("surface command applies", &command.applies)?; + if let Some(model) = &command.projected_model { + require_reference("model", model, model_scope)?; + } + if let Some(direct_projection) = &command.direct_projection { + validate_json_contract("surface command direct projection", direct_projection)?; + } + } + for projection in &surface.projections { + validate_projection( + projection, + model_scope, + projection_ids, + require_nested_fingerprints, + )?; + } + validate_surface_contract(surface)?; + Ok(()) +} + +fn validate_surface_selection(surface: &SurfaceSpec) -> ApplicationResult<()> { + match surface.selection.as_str() { + "catalog" => { + if !surface.eligible_roles.is_empty() || !surface.schema_roles.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "catalog surface `{}` cannot expose roles", + surface.id + ))); + } + } + "role" => { + if surface.eligible_roles.len() != 1 + || surface.schema_roles.len() != 1 + || surface.eligible_roles != surface.schema_roles + { + return Err(ApplicationError::InvalidSpec(format!( + "role surface `{}` must name exactly one identical eligible and schema role", + surface.id + ))); + } + } + value if value.strip_prefix("application:").is_some_and(|name| !name.is_empty()) => { + let name = value.strip_prefix("application:").expect("matched above"); + LogicalId::try_new("surface application", name.to_owned())?; + if surface.eligible_roles.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "application surface `{}` must expose at least one eligible role", + surface.id + ))); + } + if surface.schema_roles.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "application surface `{}` must expose at least one schema role", + surface.id + ))); + } + if surface + .schema_roles + .iter() + .any(|role| !surface.eligible_roles.iter().any(|eligible| eligible == role)) + { + return Err(ApplicationError::InvalidSpec(format!( + "application surface `{}` schema roles must be a subset of eligible roles", + surface.id + ))); + } + } + _ => { + return Err(ApplicationError::InvalidSpec(format!( + "surface `{}` has an invalid selection identity", + surface.id + ))) + } + } + validate_roles("surface eligible role", &surface.eligible_roles)?; + validate_roles("surface schema role", &surface.schema_roles) +} + +fn validate_roles(kind: &'static str, roles: &[String]) -> ApplicationResult<()> { + let mut previous: Option<&str> = None; + for role in roles { + LogicalId::try_new(kind, role.clone())?; + if previous.is_some_and(|previous| previous >= role.as_str()) { + return Err(ApplicationError::NonCanonical("role ordering")); + } + previous = Some(role); + } + Ok(()) +} + +fn validate_surface_argument(argument: &super::module::SurfaceArgumentSpec) -> ApplicationResult<()> { + validate_portable_text("surface argument", &argument.name)?; + validate_portable_text("surface argument kind", &argument.kind)?; + validate_portable_text("surface argument type", &argument.type_name)?; + if !matches!( + argument.kind.as_str(), + "filter" | "order" | "limit" | "offset" | "primary_key" + ) { + return Err(ApplicationError::InvalidSpec(format!( + "surface argument `{}` has unknown kind `{}`", + argument.name, argument.kind + ))); + } + Ok(()) +} + +fn validate_row_policy(value: &serde_json::Value) -> ApplicationResult<()> { + let Some(fields) = value.as_object() else { + return Err(ApplicationError::InvalidSpec( + "row policy must be a tagged object".into(), + )); + }; + let Some(kind) = fields.get("kind").and_then(serde_json::Value::as_str) else { + return Err(ApplicationError::InvalidSpec( + "row policy must declare its kind".into(), + )); + }; + match kind { + "unrestricted" | "server_only" if fields.len() == 1 => Ok(()), + "predicate" if fields.len() == 2 => { + let expression = fields.get("expression").ok_or_else(|| { + ApplicationError::InvalidSpec( + "predicate row policy must retain its expression".into(), + ) + })?; + validate_json_contract("row policy expression", expression) + } + _ => Err(ApplicationError::InvalidSpec( + "row policy contains unknown or redundant material".into(), + )), + } +} + +fn validate_relationship_keys(value: &serde_json::Value) -> ApplicationResult<()> { + let Some(fields) = value.as_object() else { + return Err(ApplicationError::InvalidSpec( + "relationship keys must be a tagged object".into(), + )); + }; + let Some(kind) = fields.get("kind").and_then(serde_json::Value::as_str) else { + return Err(ApplicationError::InvalidSpec( + "relationship keys must declare their kind".into(), + )); + }; + if kind == "embedded" { + if fields.len() != 1 { + return Err(ApplicationError::InvalidSpec( + "embedded relationship keys contain redundant material".into(), + )); + } + return Ok(()); + } + if !matches!(kind, "direct" | "through" | "through_opaque") { + return Err(ApplicationError::InvalidSpec(format!( + "relationship keys have unknown kind `{kind}`" + ))); + } + let local = fields + .get("local") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ApplicationError::InvalidSpec("relationship keys need local columns".into()))?; + let remote = fields + .get("remote") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ApplicationError::InvalidSpec("relationship keys need remote columns".into()))?; + if local.is_empty() || local.len() != remote.len() { + return Err(ApplicationError::InvalidSpec( + "relationship key columns must be non-empty and paired".into(), + )); + } + for column in local.iter().chain(remote) { + let name = column.as_str().ok_or_else(|| { + ApplicationError::InvalidSpec("relationship key columns must be strings".into()) + })?; + validate_portable_text("relationship key column", name)?; + } + let expected_len = match kind { + "direct" => 3, + "through" => 6, + "through_opaque" => 4, + _ => unreachable!(), + }; + if fields.len() != expected_len { + return Err(ApplicationError::InvalidSpec( + "relationship keys contain missing or redundant material".into(), + )); + } + for field in ["table", "source_foreign_key", "target_foreign_key"] { + if kind == "through" && !fields.contains_key(field) { + return Err(ApplicationError::InvalidSpec(format!( + "through relationship keys need `{field}`" + ))); + } + } + if kind == "through_opaque" && !fields.contains_key("dependency") { + return Err(ApplicationError::InvalidSpec( + "opaque relationship keys need a dependency identity".into(), + )); + } + let identity_fields: &[&str] = match kind { + "through" => &["table", "source_foreign_key", "target_foreign_key"], + "through_opaque" => &["dependency"], + "direct" => &[], + _ => unreachable!(), + }; + for field in identity_fields { + let value = fields.get(*field).and_then(serde_json::Value::as_str).ok_or_else(|| { + ApplicationError::InvalidSpec(format!( + "relationship key `{field}` must be a string" + )) + })?; + validate_portable_text("relationship key identity", value)?; + } + Ok(()) +} + +fn validate_command_type_spec( + definition: &super::command::CommandTypeSpec, +) -> ApplicationResult<()> { + validate_command_type_spec_at_depth(definition, 0) +} + +fn validate_command_type_spec_at_depth( + definition: &super::command::CommandTypeSpec, + depth: usize, +) -> ApplicationResult<()> { + if depth > MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "surface command type exceeds nesting depth {MAX_MANIFEST_JSON_DEPTH}" + ))); + } + validate_portable_text("surface command type", &definition.name)?; + validate_collection_len("surface command type fields", definition.fields.len())?; + let field_names = definition + .fields + .iter() + .map(|field| field.name.clone()) + .collect::>(); + validate_unique("surface command type fields", &field_names)?; + for field in &definition.fields { + validate_portable_text("surface command field", &field.name)?; + validate_portable_text("surface command field type", &field.type_name)?; + if let Some(nested) = &field.nested { + validate_command_type_spec_at_depth(nested, depth + 1)?; + } + } + Ok(()) +} + +fn validate_surface_contract(surface: &SurfaceSpec) -> ApplicationResult<()> { + let expected = surface_contract_from_spec(surface)?; + if canonical_json(&surface.contract) != expected { + return Err(ApplicationError::NonCanonical( + "surface contract material", + )); + } + Ok(()) +} + +fn validate_manifest_ownership(manifest: &ApplicationManifest) -> ApplicationResult<()> { + let mut module_commands = manifest + .modules + .iter() + .flat_map(|module| module.commands.iter().cloned()) + .collect::>(); + module_commands.sort_by(|left, right| left.id.cmp(&right.id)); + let module_commands = dedup_commands(module_commands)?; + if module_commands != manifest.commands { + return Err(ApplicationError::Collision { + kind: "command", + identity: manifest.name.clone(), + reason: "application command inventory does not equal explicit module ownership" + .into(), + }); + } + + let mut module_events = manifest + .modules + .iter() + .flat_map(|module| module.events.iter().cloned()) + .collect::>(); + let module_events = dedup_events(std::mem::take(&mut module_events))?; + if module_events != manifest.events { + return Err(ApplicationError::Collision { + kind: "event", + identity: manifest.name.clone(), + reason: "application event inventory does not equal explicit module ownership" + .into(), + }); + } + + let mut owned_projections = manifest + .modules + .iter() + .flat_map(|module| module.projections.iter().cloned()) + .chain( + manifest + .surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ) + .collect::>(); + let owned_projections = dedup_projections(std::mem::take(&mut owned_projections))?; + if owned_projections != manifest.projections { + return Err(ApplicationError::Collision { + kind: "projection", + identity: manifest.name.clone(), + reason: "application projection inventory is not closed over modules and surfaces" + .into(), + }); + } + + let mut owned_models = manifest + .modules + .iter() + .flat_map(|module| module.models.iter().cloned()) + .chain( + manifest + .surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ) + .collect::>(); + let owned_models = dedup_models(std::mem::take(&mut owned_models))?; + if owned_models != manifest.models { + return Err(ApplicationError::Collision { + kind: "model", + identity: manifest.name.clone(), + reason: "application model inventory is not closed over modules and surfaces".into(), + }); + } + + for module in &manifest.modules { + for owned_surface in &module.surfaces { + let Some(surface) = manifest.surfaces.iter().find(|surface| surface.id == owned_surface.id) + else { + return Err(ApplicationError::Missing { + kind: "surface", + identity: owned_surface.id.clone(), + }); + }; + if surface != owned_surface { + return Err(ApplicationError::Collision { + kind: "surface", + identity: surface.id.clone(), + reason: "surface ownership has incompatible canonical material".into(), + }); + } + } + } + + let commands = manifest + .commands + .iter() + .map(|command| (command.id.as_str(), command)) + .collect::>(); + let models = manifest + .models + .iter() + .map(|model| (model.id.as_str(), model)) + .collect::>(); + let projections = manifest + .projections + .iter() + .map(|projection| (projection.id.as_str(), projection)) + .collect::>(); + for surface in &manifest.surfaces { + for exposed in &surface.models { + let Some(authoritative) = models.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "model", + identity: exposed.id.clone(), + }); + }; + if *authoritative != exposed { + return Err(ApplicationError::Collision { + kind: "model", + identity: exposed.id.clone(), + reason: "surface model differs from the application model declaration".into(), + }); + } + } + for exposed in &surface.projections { + let Some(authoritative) = projections.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "projection", + identity: exposed.id.clone(), + }); + }; + if *authoritative != exposed { + return Err(ApplicationError::Collision { + kind: "projection", + identity: exposed.id.clone(), + reason: "surface projection differs from the application projection declaration" + .into(), + }); + } + } + let expected_command_ids = surface_command_closure(surface, &manifest.commands)?; + let actual_command_ids = surface + .commands + .iter() + .map(|command| command.id.clone()) + .collect::>(); + if actual_command_ids != expected_command_ids { + return Err(ApplicationError::Collision { + kind: "command", + identity: surface.id.clone(), + reason: "surface command inventory is not the exact authorized command closure" + .into(), + }); + } + for exposed in &surface.commands { + let Some(authoritative) = commands.get(exposed.id.as_str()) else { + return Err(ApplicationError::Missing { + kind: "command", + identity: exposed.id.clone(), + }); + }; + let expected_roles = surface_command_roles(surface, authoritative); + validate_surface_command_ownership(exposed, authoritative, &expected_roles)?; + } + for root in &surface.roots { + if !models.contains_key(root.model.as_str()) { + return Err(ApplicationError::Missing { + kind: "model", + identity: root.model.clone(), + }); + } + } + } + Ok(()) +} + +fn validate_surface_command_ownership( + exposed: &super::module::SurfaceCommandSpec, + authoritative: &super::command::CommandSpec, + expected_roles: &[String], +) -> ApplicationResult<()> { + if exposed.field_name != authoritative.field_name + || exposed.roles != expected_roles + || exposed.input.as_ref() != Some(&authoritative.input) + || exposed.output.as_ref() != Some(&authoritative.output) + || exposed.consistency != authoritative.consistency + || canonical_json(&exposed.defaults) != canonical_json(&authoritative.defaults) + || canonical_json(&exposed.effects) != canonical_json(&authoritative.effects) + || canonical_json(&exposed.applies) != canonical_json(&authoritative.applies) + || canonical_json(&exposed.projection_contract) + != canonical_json(&authoritative.projection_contract) + || exposed.direct_projection != authoritative.direct_projection + || exposed.projected_model != authoritative.projected_model + { + return Err(ApplicationError::Collision { + kind: "command", + identity: authoritative.id.clone(), + reason: "surface command is not compatible with its application declaration".into(), + }); + } + let authoritative_confirmations = serde_json::to_value(&authoritative.confirmations)?; + if canonical_json(&exposed.confirmations) != canonical_json(&authoritative_confirmations) { + return Err(ApplicationError::Collision { + kind: "command", + identity: authoritative.id.clone(), + reason: "surface command confirmation material is stale".into(), + }); + } + Ok(()) +} + +fn surface_command_closure( + surface: &SurfaceSpec, + commands: &[super::command::CommandSpec], +) -> ApplicationResult> { + let mut expected = commands + .iter() + .filter(|command| match surface.selection.as_str() { + "catalog" => true, + "role" => surface + .eligible_roles + .first() + .is_some_and(|role| { + command.roles.is_empty() || command.roles.iter().any(|allowed| allowed == role) + }), + value if value.starts_with("application:") => { + command.roles.is_empty() + || surface.schema_roles.iter().all(|role| { + command.roles.iter().any(|allowed| allowed == role) + }) + } + _ => false, + }) + .map(|command| command.id.clone()) + .collect::>(); + expected.sort(); + expected.dedup(); + Ok(expected) +} + +fn surface_command_roles( + surface: &SurfaceSpec, + authoritative: &super::command::CommandSpec, +) -> Vec { + match surface.selection.as_str() { + "catalog" => authoritative.roles.clone(), + "role" => surface.eligible_roles.clone(), + value if value.starts_with("application:") => surface.eligible_roles.clone(), + _ => Vec::new(), + } +} + +fn surface_contract_from_spec(surface: &SurfaceSpec) -> ApplicationResult { + let selection = match surface.selection.as_str() { + "catalog" => serde_json::json!({"kind": "catalog"}), + "role" => serde_json::json!({ + "kind": "role", + "name": surface.eligible_roles.first().ok_or_else(|| { + ApplicationError::InvalidSpec("role surface has no role identity".into()) + })?, + }), + value => serde_json::json!({ + "kind": "application", + "name": value.strip_prefix("application:").ok_or_else(|| { + ApplicationError::InvalidSpec("surface selection is not canonical".into()) + })?, + "eligible_roles": surface.eligible_roles, + "schema_roles": surface.schema_roles, + }), + }; + let models = surface + .models + .iter() + .map(|model| { + serde_json::json!({ + "model_name": model.id, + "table_name": model.table, + "object_name": model.object, + "columns": model.fields, + "relationships": model.relationships, + "primary_key": model.primary_key, + "row_policy": model.row_policy, + "role_limit": model.role_limit, + "aggregations": model.aggregations, + }) + }) + .collect::>(); + let roots = surface + .roots + .iter() + .map(|root| { + serde_json::json!({ + "operation": root.operation, + "name": root.name, + "kind": root.kind, + "object": root.object, + "model_name": root.model, + "arguments": root.arguments, + "dependencies": root.dependencies, + "default_limit": root.default_limit, + "max_limit": root.max_limit, + }) + }) + .collect::>(); + let commands = surface + .commands + .iter() + .map(|command| { + serde_json::json!({ + "command_name": command.id, + "field_name": command.field_name, + "roles": command.roles, + "input": surface_command_type_value(command.input.as_ref()), + "output": surface_command_type_value(command.output.as_ref()), + "consistency": command.consistency, + "input_defaults": command.defaults, + "effects": command.effects, + "confirmations": command.confirmations, + "projected_model": command.projected_model, + "direct_projection": command.direct_projection, + "projections": command.projection_contract, + "confirmation_unavailable": command.confirmation_unavailable, + }) + }) + .collect::>(); + let projectors = surface + .projections + .iter() + .map(|projection| { + serde_json::json!({ + "name": projection.id, + "facts": projection.facts, + "models": projection.models, + "dependencies": projection.dependencies, + "change_epoch": projection.change_epoch, + "partition": projection.partition, + "kind": if projection.direct { "direct" } else { "async" }, + "modeled": projection.modeled, + }) + }) + .collect::>(); + Ok(canonical_json(&serde_json::json!({ + "version": 1, + "selection": selection, + "dialect": surface.dialect, + "aggregates": surface.aggregates, + "subscriptions": surface.subscriptions, + "default_limit": surface.default_limit, + "max_limit": surface.max_limit, + "models": models, + "roots": roots, + "comparison_ops": surface.comparison_ops, + "commands": commands, + "commands_attached": surface.commands_attached, + "projectors": projectors, + "projectors_attached": surface.projectors_attached, + }))) +} + +fn surface_command_type_value( + definition: Option<&super::command::CommandTypeSpec>, +) -> serde_json::Value { + let Some(definition) = definition else { + return serde_json::Value::Null; + }; + serde_json::json!({ + "name": definition.name, + "fields": definition.fields.iter().map(|field| { + serde_json::json!({ + "name": field.name, + "type_name": field.type_name, + "nullable": field.nullable, + "list": field.list, + "item_nullable": field.item_nullable, + "nested": field.nested.as_deref().map(|nested| surface_command_type_value(Some(nested))), + }) + }).collect::>(), + }) +} + +fn validate_fingerprint( + kind: &'static str, + identity: &str, + fingerprint: &str, + value: &T, + required: bool, +) -> ApplicationResult<()> { + if fingerprint.is_empty() { + if required { + return Err(ApplicationError::NonCanonical(match kind { + "module" => "module fingerprint", + "surface" => "surface fingerprint", + "projection" => "projection fingerprint", + "model" => "model fingerprint", + "command" => "command fingerprint", + _ => "nested fingerprint", + })); + } + return Ok(()); + } + let mut value = serde_json::to_value(value)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + } + let expected = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + if expected != fingerprint { + return Err(ApplicationError::NonCanonical(match kind { + "module" => "module fingerprint", + "surface" => "surface fingerprint", + "projection" => "projection fingerprint", + "model" => "model fingerprint", + "command" => "command fingerprint", + _ => "nested fingerprint", + })); + } + let _ = identity; + Ok(()) +} + +fn require_reference( + kind: &'static str, + identity: &str, + references: &HashSet<&str, T>, +) -> ApplicationResult<()> { + if !references.contains(identity) { + return Err(ApplicationError::Missing { + kind, + identity: identity.into(), + }); + } + Ok(()) +} + +fn validate_collection_len(kind: &'static str, length: usize) -> ApplicationResult<()> { + if length > MAX_MANIFEST_COLLECTION_ITEMS { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} count exceeds {MAX_MANIFEST_COLLECTION_ITEMS}" + ))); + } + Ok(()) +} + +fn validate_portable_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidIdentity { + kind, + value: value.into(), + reason: "must be a bounded portable logical value", + }); + } + Ok(()) +} + +fn validate_artifact_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_MANIFEST_STRING_BYTES + || value.contains('\0') + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must be a bounded artifact provenance value" + ))); + } + Ok(()) +} + +fn validate_sha256_text(kind: &'static str, value: &str) -> ApplicationResult<()> { + validate_portable_text(kind, value)?; + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} must use the sha256:<64 lowercase hex> form" + ))); + } + Ok(()) +} + +fn validate_json_contract(kind: &'static str, value: &serde_json::Value) -> ApplicationResult<()> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > MAX_MANIFEST_JSON_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds {MAX_MANIFEST_JSON_BYTES} JSON bytes" + ))); + } + fn walk( + kind: &'static str, + value: &serde_json::Value, + depth: usize, + ) -> ApplicationResult<()> { + if depth > MAX_MANIFEST_JSON_DEPTH { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} exceeds JSON depth {MAX_MANIFEST_JSON_DEPTH}" + ))); + } + match value { + serde_json::Value::String(value) => { + if value.len() > MAX_MANIFEST_STRING_BYTES || value.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL string material" + ))); + } + } + serde_json::Value::Array(values) => { + validate_collection_len(kind, values.len())?; + for value in values { + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Object(fields) => { + validate_collection_len(kind, fields.len())?; + for (key, value) in fields { + if key.len() > MAX_MANIFEST_STRING_BYTES || key.contains('\0') { + return Err(ApplicationError::InvalidSpec(format!( + "{kind} contains oversized or NUL object-key material" + ))); + } + walk(kind, value, depth + 1)?; + } + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} + } + Ok(()) + } + walk(kind, value, 0) +} + +fn dedup_commands(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("command", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +fn dedup_events( + mut values: Vec, +) -> ApplicationResult> { + values.sort_by(|left, right| { + (left.name.as_str(), left.version, left.body_fingerprint.as_str()).cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) + }); + let mut out = Vec::new(); + for value in values { + if let Some(existing) = out.iter().find(|existing: &&super::command::EventSpec| { + existing.name == value.name + }) { + if *existing != value { + return Err(ApplicationError::Collision { + kind: "event", + identity: value.name, + reason: "same identity has incompatible event schemas".into(), + }); + } + } else { + out.push(value); + } + } + Ok(out) +} + +fn dedup_projections(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("projection", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +fn dedup_models(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + let mut out = Vec::new(); + for value in values { + if let Some(existing) = out.iter().find(|existing: &&ModelSpec| existing.id == value.id) { + if *existing != value { + return Err(ApplicationError::Collision { + kind: "model", + identity: value.id, + reason: "same identity has incompatible model schemas".into(), + }); + } + } else { + out.push(value); + } + } + Ok(out) +} + +fn dedup_surfaces(mut values: Vec) -> ApplicationResult> { + values.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique_ids("surface", values.iter().map(|value| value.id.clone()))?; + Ok(values) +} + +fn validate_unique_ids( + kind: &'static str, + ids: impl IntoIterator, +) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in ids { + if !seen.insert(identity.clone()) { + return Err(ApplicationError::Duplicate { kind, identity }); + } + } + Ok(()) +} + +fn validate_unique(kind: &'static str, identities: &[String]) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in identities { + if !seen.insert(identity) { + return Err(ApplicationError::Duplicate { + kind, + identity: identity.clone(), + }); + } + } + Ok(()) +} + +fn validate_sorted_unique(kind: &'static str, identities: &[String]) -> ApplicationResult<()> { + validate_unique(kind, identities)?; + if identities.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(ApplicationError::NonCanonical(kind)); + } + Ok(()) +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 00000000..c048e109 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,86 @@ +//! Placement-independent application composition. +//! +//! The application module is deliberately feature-light. It contains the +//! portable contract values used by contract-only packages as well as the +//! erased executable mounts used at a heterogeneous runtime boundary. SQL, +//! HTTP, GraphQL execution, brokers, and deployment clients are not required +//! to construct these values. + +mod capability; +mod command; +mod error; +mod identity; +mod manifest; +mod module; +mod mount; +mod plan; +mod registration; +mod runtime_host; +mod topology; + +pub use capability::{ + Capability, CapabilityReason, CapabilityRequirement, SchemaLifecycleRequirement, +}; +pub use command::{ + CommandDefinition, CommandMount, CommandMountFuture, CommandMountHandler, CommandMountRegistrar, + CommandSpec, CommandTypeField, CommandTypeSpec, EventSpec, TypeSpec, +}; +pub(crate) use command::{ + CommandMountExecution, CommandMountExecutionError, CommandMountExecutionFuture, + CommandMountExecutionResult, CommandMountInvocation, +}; +pub use error::{ApplicationError, ApplicationResult}; +pub use identity::{canonical_json, sha256_fingerprint, LogicalId}; +pub use manifest::{ + ApplicationExtension, ApplicationManifest, ManifestFingerprint, ManifestProvenance, + APPLICATION_MANIFEST_SCHEMA_VERSION, MAX_APPLICATION_MANIFEST_BYTES, + MAX_MANIFEST_COLLECTION_ITEMS, MAX_MANIFEST_JSON_BYTES, MAX_MANIFEST_JSON_DEPTH, + MAX_MANIFEST_STRING_BYTES, +}; +pub use module::{ + ModelFieldSpec, ModelRelationshipSpec, ModelSpec, Module, ModuleBuilder, ModuleManifest, + ProjectionSpec, SurfaceAggregateSpec, SurfaceArgumentSpec, SurfaceCommandSpec, SurfaceRootSpec, + SurfaceSpec, +}; +pub use mount::{MountSelector, ProcessPreset}; +pub use plan::{ + compile_deployment_plan, DeploymentPlan, PlanFingerprint, ProcessIntent, ProcessPlan, + DEPLOYMENT_PLAN_SCHEMA_VERSION, MAX_DEPLOYMENT_PLAN_BYTES, +}; +pub use registration::{Application, ApplicationBuilder, ContractCompiler}; +pub use runtime_host::{bind_single_process, CapabilityProviders, RuntimeHost}; +pub use topology::TopologyIntent; + +/// Compile-time duplicate check emitted by the module macro for generated +/// command definition IDs. It compares the complete IDs, not a truncated or +/// hashed surrogate, so a hash collision cannot reject a valid module or hide +/// a duplicate declaration. +pub const fn assert_unique_command_ids(ids: &[&str]) { + let mut index = 0; + while index < ids.len() { + let mut other = index + 1; + while other < ids.len() { + if same_const_str(ids[index], ids[other]) { + panic!("duplicate command identity in module declaration"); + } + other += 1; + } + index += 1; + } +} + +const fn same_const_str(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + if left.len() != right.len() { + return false; + } + let mut index = 0; + while index < left.len() { + if left[index] != right[index] { + return false; + } + index += 1; + } + true +} diff --git a/src/application/module.rs b/src/application/module.rs new file mode 100644 index 00000000..44993f33 --- /dev/null +++ b/src/application/module.rs @@ -0,0 +1,1053 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use super::command::{CommandDefinition, CommandMount, CommandSpec, CommandTypeSpec, EventSpec}; +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; +use crate::graphql::command_contract::TypedCommandContract; +use crate::graphql::surface::{ + RootKind, Surface, SurfaceArgument, SurfaceCommand, SurfaceCommandShape, + SurfaceProjectionOwner, SurfaceRelationshipKeys, SurfaceSelection, SurfaceTypeDef, +}; + +/// One exposed model field in a portable surface/module artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelFieldSpec { + pub name: String, + pub scalar: String, + pub nullable: bool, +} + +/// Stable model identity and authorized field inventory. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelSpec { + pub id: String, + pub table: String, + pub object: String, + pub fields: Vec, + pub primary_key: Vec, + pub relationships: Vec, + pub row_policy: serde_json::Value, + pub role_limit: Option, + pub aggregations: bool, + pub fingerprint: String, +} + +/// Complete authorized relationship material retained by a model contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelRelationshipSpec { + pub name: String, + pub target_model: String, + pub target_object: String, + pub kind: String, + pub list: bool, + pub nullable: bool, + pub arguments: Vec, + pub keys: serde_json::Value, + pub dependencies: Vec, + pub aggregate: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceArgumentSpec { + pub name: String, + pub kind: String, + pub type_name: String, + pub nullable: bool, + pub list: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceAggregateSpec { + pub name: String, + pub type_name: String, + pub arguments: Vec, + pub dependencies: Vec, +} + +impl ModelSpec { + /// Construct a minimal portable model contract for composition tests and + /// plan fixtures. Surfaces may still re-author richer model material. + pub fn try_new( + id: impl Into, + table: impl Into, + fields: impl IntoIterator, + primary_key: impl IntoIterator>, + ) -> ApplicationResult { + let id = LogicalId::try_new("model", id)?.into_string(); + let table = LogicalId::try_new("model table", table)?.into_string(); + let mut fields = fields.into_iter().collect::>(); + fields.sort_by(|left, right| left.name.cmp(&right.name)); + let mut primary_key = primary_key + .into_iter() + .map(Into::into) + .collect::>(); + primary_key.sort(); + primary_key.dedup(); + if fields.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{id}` must declare at least one field" + ))); + } + if primary_key.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "model `{id}` must declare a primary key" + ))); + } + let mut spec = Self { + id: id.clone(), + table, + object: id, + fields, + primary_key, + relationships: Vec::new(), + row_policy: serde_json::json!({ "kind": "unrestricted" }), + role_limit: None, + aggregations: false, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn refresh_fingerprint(&mut self) -> ApplicationResult<()> { + let mut value = serde_json::to_value(&*self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + +} + +/// Portable projection-owner identity aggregated into a module. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionSpec { + pub id: String, + pub facts: Vec, + pub models: Vec, + pub dependencies: Vec, + pub direct: bool, + pub change_epoch: Option, + pub modeled_programs: Vec, + /// Canonical behavior-affecting program/binding material selected from the + /// authoritative Surface IR. Runtime routes and physical service config + /// are intentionally absent. + #[serde(default)] + pub modeled: Vec, + #[serde(default)] + pub partition: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_fingerprint: Option, + pub fingerprint: String, +} + +impl ProjectionSpec { + pub fn try_new( + id: impl Into, + facts: impl IntoIterator>, + models: impl IntoIterator>, + ) -> ApplicationResult { + let id = LogicalId::try_new("projection", id)?.into_string(); + let mut spec = Self { + id, + facts: facts.into_iter().map(Into::into).collect(), + models: models.into_iter().map(Into::into).collect(), + dependencies: Vec::new(), + direct: false, + change_epoch: None, + modeled_programs: Vec::new(), + modeled: Vec::new(), + partition: serde_json::Value::Null, + catalog_fingerprint: None, + fingerprint: String::new(), + }; + spec.canonicalize(); + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Mark the projection as direct (Atomic seal) and refresh its fingerprint. + pub fn with_direct(mut self, direct: bool) -> ApplicationResult { + self.direct = direct; + self.canonicalize(); + self.refresh_fingerprint()?; + Ok(self) + } + + fn from_owner(owner: &SurfaceProjectionOwner) -> ApplicationResult { + let mut spec = Self { + id: LogicalId::try_new("projection", owner.name.clone())?.into_string(), + facts: owner.facts.clone(), + models: owner.models.clone(), + dependencies: owner.dependencies.clone(), + direct: owner.is_direct(), + change_epoch: owner.change_epoch.clone(), + modeled_programs: owner + .modeled + .iter() + .map(|modeled| modeled.program_id().to_string()) + .collect(), + modeled: owner + .modeled + .iter() + .map(|modeled| modeled.canonical_contract_value()) + .collect::, _>>() + .map_err(ApplicationError::InvalidSpec)?, + partition: serde_json::to_value(&owner.partition)?, + catalog_fingerprint: None, + fingerprint: String::new(), + }; + spec.canonicalize(); + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Reference an existing canonical projection catalog without copying + /// executable routes or handler values into the module artifact. + pub fn from_catalog( + id: impl Into, + catalog: &crate::projection::catalog::ProjectionCatalog, + ) -> ApplicationResult { + let bytes = catalog + .canonical_bytes() + .map_err(|error| ApplicationError::Canonical(error.to_string()))?; + let mut spec = Self::try_new(id, Vec::::new(), Vec::::new())?; + spec.catalog_fingerprint = Some(sha256_fingerprint(&bytes)); + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn canonicalize(&mut self) { + self.facts.sort(); + self.facts.dedup(); + self.models.sort(); + self.models.dedup(); + self.dependencies.sort(); + self.dependencies.dedup(); + self.modeled_programs.sort(); + self.modeled_programs.dedup(); + } + + fn refresh_fingerprint(&mut self) -> ApplicationResult<()> { + let mut value = serde_json::to_value(&*self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + + /// Canonical projection identity material with the derived fingerprint + /// removed. This is the same byte source used by generated projection + /// owners and by manifest validation. + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } + +} + +/// A root field identity retained by a surface contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceRootSpec { + pub operation: String, + pub name: String, + pub kind: String, + pub object: String, + pub model: String, + pub dependencies: Vec, + pub arguments: Vec, + pub default_limit: Option, + pub max_limit: Option, +} + +/// A command shape as exposed by a selected surface. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceCommandSpec { + pub id: String, + pub field_name: String, + pub roles: Vec, + pub input: Option, + pub output: Option, + pub consistency: crate::graphql::CommandConsistency, + pub defaults: serde_json::Value, + pub effects: serde_json::Value, + pub confirmations: serde_json::Value, + pub projection_contract: serde_json::Value, + pub applies: serde_json::Value, + pub direct_projection: Option, + pub projected_model: Option, + pub confirmation_unavailable: bool, +} + +/// Authorized, placement-independent surface identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceSpec { + pub id: String, + pub selection: String, + pub eligible_roles: Vec, + pub schema_roles: Vec, + pub models: Vec, + pub roots: Vec, + pub commands: Vec, + pub projections: Vec, + pub dialect: String, + pub aggregates: bool, + pub subscriptions: bool, + pub default_limit: u64, + pub max_limit: u64, + pub comparison_ops: BTreeMap>, + pub commands_attached: bool, + pub projectors_attached: bool, + /// Canonical lossless Surface IR snapshot. It is the identity authority + /// for SDL/client compilation and must never be reconstructed from tables. + pub contract: serde_json::Value, + pub fingerprint: String, +} + +impl SurfaceSpec { + pub fn try_new(id: impl Into) -> ApplicationResult { + let id = LogicalId::try_new("surface", id)?.into_string(); + let mut spec = Self { + id, + selection: "catalog".into(), + eligible_roles: Vec::new(), + schema_roles: Vec::new(), + models: Vec::new(), + roots: Vec::new(), + commands: Vec::new(), + projections: Vec::new(), + dialect: "sqlite".into(), + aggregates: true, + subscriptions: true, + default_limit: 100, + max_limit: 1000, + comparison_ops: BTreeMap::new(), + commands_attached: false, + projectors_attached: false, + contract: serde_json::json!({ + "version": 1, + "selection": {"kind": "catalog"}, + "dialect": "sqlite", + "aggregates": true, + "subscriptions": true, + "default_limit": 100, + "max_limit": 1000, + "models": [], + "roots": [], + "comparison_ops": {}, + "commands": [], + "commands_attached": false, + "projectors": [], + "projectors_attached": false, + }), + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + /// Compile the already-authorized Surface IR without constructing a + /// repository, service, lock manager, or handler mount. + pub fn from_surface(id: impl Into, surface: &Surface) -> ApplicationResult { + let id = LogicalId::try_new("surface", id)?.into_string(); + let (selection, mut eligible_roles, mut schema_roles) = match &surface.selection { + SurfaceSelection::Catalog => ("catalog".to_owned(), Vec::new(), Vec::new()), + SurfaceSelection::Role { name } => { + ("role".to_owned(), vec![name.clone()], vec![name.clone()]) + } + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => ( + format!("application:{name}"), + eligible_roles.clone(), + schema_roles.clone(), + ), + }; + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + + let mut models = surface + .models + .values() + .map(|model| { + let mut spec = ModelSpec { + id: LogicalId::try_new("model", model.model_name.clone())?.into_string(), + table: model.table_name.clone(), + object: model.object_name.clone(), + fields: model + .columns + .iter() + .map(|field| ModelFieldSpec { + name: field.name.clone(), + scalar: field.scalar.clone(), + nullable: field.nullable, + }) + .collect(), + primary_key: model.primary_key.clone(), + relationships: model + .relationships + .iter() + .map(surface_relationship_spec) + .collect::>>()?, + row_policy: surface_row_policy(&model.row_policy)?, + role_limit: model.role_limit, + aggregations: model.aggregations, + fingerprint: String::new(), + }; + spec.fields + .sort_by(|left, right| left.name.cmp(&right.name)); + spec.primary_key.sort(); + spec.relationships.sort_by(|left, right| left.name.cmp(&right.name)); + spec.refresh_fingerprint()?; + Ok(spec) + }) + .collect::>>()?; + models.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut roots = surface + .query_fields + .iter() + .map(|root| ("query", root)) + .chain(surface.subscription_fields.iter().map(|root| ("subscription", root))) + .map(|(operation, root)| SurfaceRootSpec { + operation: operation.into(), + name: root.name.clone(), + kind: match root.kind { + RootKind::List => "list", + RootKind::ByPk => "by_pk", + RootKind::Aggregate => "aggregate", + } + .into(), + object: root.object.clone(), + model: root.model_name.clone(), + dependencies: root.dependencies.clone(), + arguments: root.arguments.iter().map(surface_argument_spec).collect(), + default_limit: root.default_limit, + max_limit: root.max_limit, + }) + .collect::>(); + roots.sort_by(|left, right| { + (left.operation.as_str(), left.name.as_str()) + .cmp(&(right.operation.as_str(), right.name.as_str())) + }); + + let mut commands = surface + .commands + .iter() + .map(surface_command_spec) + .collect::>>()?; + commands.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut projections = surface + .projectors + .iter() + .map(ProjectionSpec::from_owner) + .collect::>>()?; + projections.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut spec = Self { + id, + selection, + eligible_roles, + schema_roles, + models, + roots, + commands, + projections, + dialect: format!("{:?}", surface.dialect).to_ascii_lowercase(), + aggregates: surface.aggregates, + subscriptions: surface.subscriptions, + default_limit: surface.default_limit, + max_limit: surface.max_limit, + comparison_ops: surface.comparison_ops.clone(), + commands_attached: surface.commands_attached, + projectors_attached: surface.projectors_attached, + contract: surface + .canonical_contract_value() + .map_err(ApplicationError::InvalidSpec)?, + fingerprint: String::new(), + }; + spec.refresh_fingerprint()?; + Ok(spec) + } + + fn refresh_fingerprint(&mut self) -> ApplicationResult<()> { + let mut value = serde_json::to_value(&*self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); + } + self.fingerprint = sha256_fingerprint(&serde_json::to_vec(&canonical_json(&value))?); + Ok(()) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert("fingerprint".into(), serde_json::Value::String(String::new())); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } +} + +fn surface_command_spec(command: &SurfaceCommand) -> ApplicationResult { + Ok(SurfaceCommandSpec { + id: LogicalId::try_new("command", command.command_name.clone())?.into_string(), + field_name: command.field_name.clone(), + roles: command.roles.clone(), + input: surface_shape_spec(&command.input), + output: surface_shape_spec(&command.output), + consistency: command.consistency, + defaults: serde_json::to_value(&command.input_defaults)?, + effects: serde_json::to_value(&command.effects)?, + confirmations: serde_json::to_value(&command.confirmations)?, + projection_contract: serde_json::to_value(&command.projections)?, + applies: serde_json::to_value(&command.projections.previews)?, + direct_projection: command + .direct_projection + .as_ref() + .map(crate::graphql::command_contract::CommandDirectProjectionTarget::canonical_value), + projected_model: command + .projected_model + .as_ref() + .map(|model| model.model.clone()), + confirmation_unavailable: command.confirmation_unavailable, + }) +} + +fn surface_argument_spec(argument: &SurfaceArgument) -> SurfaceArgumentSpec { + SurfaceArgumentSpec { + name: argument.name.clone(), + kind: match argument.kind { + crate::graphql::surface::SurfaceArgumentKind::Filter => "filter", + crate::graphql::surface::SurfaceArgumentKind::Order => "order", + crate::graphql::surface::SurfaceArgumentKind::Limit => "limit", + crate::graphql::surface::SurfaceArgumentKind::Offset => "offset", + crate::graphql::surface::SurfaceArgumentKind::PrimaryKey => "primary_key", + } + .into(), + type_name: argument.type_name.clone(), + nullable: argument.nullable, + list: argument.list, + } +} + +fn surface_relationship_spec( + relationship: &crate::graphql::surface::RelField, +) -> ApplicationResult { + Ok(ModelRelationshipSpec { + name: relationship.name.clone(), + target_model: relationship.target_model.clone(), + target_object: relationship.target_object.clone(), + kind: format!("{:?}", relationship.kind).to_ascii_lowercase(), + list: relationship.list, + nullable: relationship.nullable, + arguments: relationship + .arguments + .iter() + .map(surface_argument_spec) + .collect(), + keys: surface_relationship_keys(&relationship.keys), + dependencies: relationship.dependencies.clone(), + aggregate: relationship.aggregate.as_ref().map(|aggregate| SurfaceAggregateSpec { + name: aggregate.name.clone(), + type_name: aggregate.type_name.clone(), + arguments: aggregate + .arguments + .iter() + .map(surface_argument_spec) + .collect(), + dependencies: aggregate.dependencies.clone(), + }), + }) +} + +fn surface_relationship_keys(keys: &SurfaceRelationshipKeys) -> serde_json::Value { + match keys { + SurfaceRelationshipKeys::Direct { local, remote } => { + serde_json::json!({"kind": "direct", "local": local, "remote": remote}) + } + SurfaceRelationshipKeys::Through { + local, + remote, + table, + source_foreign_key, + target_foreign_key, + } => serde_json::json!({ + "kind": "through", + "local": local, + "remote": remote, + "table": table, + "source_foreign_key": source_foreign_key, + "target_foreign_key": target_foreign_key, + }), + SurfaceRelationshipKeys::ThroughOpaque { + local, + remote, + dependency, + } => serde_json::json!({ + "kind": "through_opaque", + "local": local, + "remote": remote, + "dependency": dependency, + }), + SurfaceRelationshipKeys::Embedded => serde_json::json!({"kind": "embedded"}), + } +} + +fn surface_row_policy( + policy: &crate::graphql::surface::SurfaceRowPolicy, +) -> ApplicationResult { + Ok(match policy { + crate::graphql::surface::SurfaceRowPolicy::Unrestricted => { + serde_json::json!({"kind": "unrestricted"}) + } + crate::graphql::surface::SurfaceRowPolicy::Predicate(predicate) => serde_json::json!({ + "kind": "predicate", + "expression": predicate, + }), + crate::graphql::surface::SurfaceRowPolicy::ServerOnly => { + serde_json::json!({"kind": "server_only"}) + } + }) +} + +fn surface_shape_spec(shape: &SurfaceCommandShape) -> Option { + match shape { + SurfaceCommandShape::None => None, + SurfaceCommandShape::Typed(definition) => Some(surface_type_spec(definition)), + } +} + +fn surface_type_spec(definition: &SurfaceTypeDef) -> CommandTypeSpec { + CommandTypeSpec { + name: definition.name.clone(), + fields: definition + .fields + .iter() + .map(|field| super::command::CommandTypeField { + name: field.name.clone(), + type_name: field.type_name.clone(), + nullable: field.nullable, + list: field.list, + item_nullable: field.item_nullable, + nested: field.nested.as_deref().map(surface_type_spec).map(Box::new), + }) + .collect(), + } +} + +/// The serializable logical portion of a module. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModuleManifest { + pub id: String, + pub commands: Vec, + pub events: Vec, + pub projections: Vec, + pub models: Vec, + pub surfaces: Vec, + pub required_capabilities: Vec, + pub fingerprint: String, +} + +impl ModuleManifest { + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "fingerprint".into(), + serde_json::Value::String(String::new()), + ); + } + serde_json::to_vec(&canonical_json(&value)).map_err(Into::into) + } +} + +/// One explicit logical composition unit with optional executable mounts. +pub struct Module { + manifest: ModuleManifest, + mounts: Vec, + definitions: Vec, +} + +impl Clone for Module { + fn clone(&self) -> Self { + Self { + manifest: self.manifest.clone(), + mounts: self.mounts.clone(), + definitions: self.definitions.clone(), + } + } +} + +impl std::fmt::Debug for Module { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Module") + .field("id", &self.manifest.id) + .field("commands", &self.manifest.commands.len()) + .field("mounts", &self.mounts.len()) + .finish() + } +} + +impl serde::Serialize for Module { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.manifest.serialize(serializer) + } +} + +impl Module { + pub fn new(id: impl Into) -> ModuleBuilder { + ModuleBuilder::new(id) + } + + pub fn builder(id: impl Into) -> ModuleBuilder { + ModuleBuilder::new(id) + } + + pub fn manifest(&self) -> &ModuleManifest { + &self.manifest + } + + pub fn id(&self) -> &str { + &self.manifest.id + } + + pub fn commands(&self) -> &[CommandSpec] { + &self.manifest.commands + } + + pub fn surfaces(&self) -> &[SurfaceSpec] { + &self.manifest.surfaces + } + + pub fn mounts(&self) -> &[CommandMount] { + &self.mounts + } + + pub fn definitions(&self) -> &[CommandDefinition] { + &self.definitions + } + + /// Return the exact typed command inventory retained by generated command + /// definitions. A manually assembled `CommandSpec` has no typed source + /// contract and therefore cannot be used to bind commands to a Surface. + pub(crate) fn typed_command_contracts(&self) -> Result, String> { + let mut contracts = Vec::with_capacity(self.definitions.len()); + for definition in &self.definitions { + let Some(contract) = definition.typed_contract() else { + return Err(format!( + "module `{}` command `{}` has no retained typed command contract", + self.id(), + definition.spec().id + )); + }; + let expected = CommandSpec::from_contract(contract) + .map_err(|error| format!("cannot compile typed command contract: {error}"))?; + if expected != *definition.spec() { + return Err(format!( + "module `{}` command `{}` has a stale portable spec beside its typed command contract", + self.id(), + definition.spec().id + )); + } + contracts.push(contract.clone()); + } + Ok(contracts) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + self.manifest.canonical_bytes() + } +} + +/// Fluent explicit module authoring API. +pub struct ModuleBuilder { + id: String, + definitions: Vec, + projections: Vec, + models: Vec, + surfaces: Vec, + required_capabilities: Vec, +} + +impl ModuleBuilder { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + definitions: Vec::new(), + projections: Vec::new(), + models: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + } + } + + /// Add one declaration whose optional runtime mount is derived from the + /// same command spec. + pub fn command_definition(mut self, definition: CommandDefinition) -> Self { + self.definitions.push(definition); + self + } + + pub fn command_definitions( + mut self, + definitions: impl IntoIterator, + ) -> Self { + self.definitions.extend(definitions); + self + } + + pub fn projection(mut self, projection: ProjectionSpec) -> Self { + self.projections.push(projection); + self + } + + pub fn projections(mut self, projections: impl IntoIterator) -> Self { + self.projections.extend(projections); + self + } + + pub fn model(mut self, model: ModelSpec) -> Self { + self.models.push(model); + self + } + + pub fn models(mut self, models: impl IntoIterator) -> Self { + self.models.extend(models); + self + } + + pub fn surface(mut self, surface: SurfaceSpec) -> Self { + self.surfaces.push(surface); + self + } + + pub fn surfaces(mut self, surfaces: impl IntoIterator) -> Self { + self.surfaces.extend(surfaces); + self + } + + pub fn required_capability(mut self, capability: impl Into) -> Self { + self.required_capabilities.push(capability.into()); + self + } + + pub fn required_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.required_capabilities + .extend(capabilities.into_iter().map(Into::into)); + self + } + + pub fn build(self) -> ApplicationResult { + let id = LogicalId::try_new("module", self.id)?.into_string(); + let mut definitions = self.definitions; + definitions.sort_by(|left, right| left.spec().id.cmp(&right.spec().id)); + let mut commands = definitions + .iter() + .map(|definition| definition.spec().clone()) + .collect::>(); + commands.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique("command", commands.iter().map(|command| command.id.clone()))?; + for command in &commands { + command.validate()?; + command.validate_fingerprint()?; + } + + for definition in &definitions { + if let Some(contract) = definition.typed_contract() { + let expected = CommandSpec::from_contract(contract)?; + if expected != *definition.spec() { + return Err(ApplicationError::Collision { + kind: "command", + identity: definition.spec().id.clone(), + reason: "typed command contract and portable spec disagree".into(), + }); + } + } + } + + let mut mounts = definitions + .iter() + .filter_map(|definition| definition.mount().cloned()) + .collect::>(); + let command_ids = commands + .iter() + .map(|command| command.id.as_str()) + .collect::>(); + for mount in &mounts { + if !command_ids.contains(mount.spec().id.as_str()) { + return Err(ApplicationError::Missing { + kind: "command", + identity: mount.spec().id.clone(), + }); + } + let Some(command) = commands + .iter() + .find(|command| command.id == mount.spec().id) + else { + continue; + }; + if command.fingerprint != mount.spec().fingerprint { + return Err(ApplicationError::Collision { + kind: "command", + identity: command.id.clone(), + reason: "executable mount spec differs from module command spec".into(), + }); + } + } + + mounts.sort_by(|left, right| left.spec().id.cmp(&right.spec().id)); + validate_unique( + "command mount", + mounts.iter().map(|mount| mount.spec().id.clone()), + )?; + + let mut surfaces = self.surfaces; + surfaces.sort_by(|left, right| left.id.cmp(&right.id)); + validate_unique("surface", surfaces.iter().map(|surface| surface.id.clone()))?; + + let mut projections = self.projections; + projections.extend( + surfaces + .iter() + .flat_map(|surface| surface.projections.iter().cloned()), + ); + projections.sort_by(|left, right| left.id.cmp(&right.id)); + projections = dedup_identical("projection", projections, |projection| projection.id.clone())?; + + let mut events = commands + .iter() + .flat_map(|command| command.emits.iter().cloned()) + .collect::>(); + events.sort_by(|left, right| { + ( + left.name.as_str(), + left.version, + left.body_fingerprint.as_str(), + ) + .cmp(&( + right.name.as_str(), + right.version, + right.body_fingerprint.as_str(), + )) + }); + let mut event_by_name = BTreeMap::new(); + for event in events { + if let Some(existing) = event_by_name.insert(event.name.clone(), event.clone()) { + if existing != event { + return Err(ApplicationError::Collision { + kind: "event", + identity: event.name, + reason: "event selectors with one name disagree".into(), + }); + } + } + } + let events = event_by_name.into_values().collect(); + + let mut models = self.models; + models.extend( + surfaces + .iter() + .flat_map(|surface| surface.models.iter().cloned()), + ); + models.sort_by(|left, right| left.id.cmp(&right.id)); + models = dedup_identical("model", models, |model| model.id.clone())?; + + let mut required_capabilities = self.required_capabilities; + required_capabilities.sort(); + required_capabilities.dedup(); + + let mut manifest = ModuleManifest { + id, + commands, + events, + projections, + models, + surfaces, + required_capabilities, + fingerprint: String::new(), + }; + manifest.fingerprint = sha256_fingerprint(&manifest.canonical_bytes()?); + Ok(Module { + manifest, + mounts, + definitions, + }) + } +} + +fn validate_unique( + kind: &'static str, + identities: impl IntoIterator, +) -> ApplicationResult<()> { + let mut seen = BTreeSet::new(); + for identity in identities { + if !seen.insert(identity.clone()) { + return Err(ApplicationError::Duplicate { kind, identity }); + } + } + Ok(()) +} + +fn dedup_identical( + kind: &'static str, + values: Vec, + identity: impl Fn(&T) -> String, +) -> ApplicationResult> { + let mut out = Vec::new(); + let mut seen = BTreeMap::::new(); + for value in values { + let id = identity(&value); + if let Some(existing) = seen.get(&id) { + if existing != &value { + return Err(ApplicationError::Collision { + kind, + identity: id, + reason: "same identity has incompatible portable definitions".into(), + }); + } + continue; + } + seen.insert(id, value); + } + out.extend(seen.into_values()); + Ok(out) +} diff --git a/src/application/mount.rs b/src/application/mount.rs new file mode 100644 index 00000000..05c7afe6 --- /dev/null +++ b/src/application/mount.rs @@ -0,0 +1,184 @@ +//! Composable mount algebra and process-role presets. +//! +//! Named presets expand to ordinary mount selectors. They are never a closed +//! capability enum — arbitrary mixes remain expressible through the same +//! algebra. + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::LogicalId; +use super::manifest::ApplicationManifest; + +/// One logical mount selected into a process. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum MountSelector { + /// Local command handler mount for a portable command identity. + Command { id: String }, + /// Projection program mount (direct or eventual) for a projection id. + Projector { id: String }, + /// Query/live surface mount for a surface identity. + Surface { id: String }, + /// Explicit application extension mount. + Extension { id: String }, +} + +impl MountSelector { + pub fn command(id: impl Into) -> ApplicationResult { + Ok(Self::Command { + id: LogicalId::try_new("command mount", id)?.into_string(), + }) + } + + pub fn projector(id: impl Into) -> ApplicationResult { + Ok(Self::Projector { + id: LogicalId::try_new("projector mount", id)?.into_string(), + }) + } + + pub fn surface(id: impl Into) -> ApplicationResult { + Ok(Self::Surface { + id: LogicalId::try_new("surface mount", id)?.into_string(), + }) + } + + pub fn extension(id: impl Into) -> ApplicationResult { + Ok(Self::Extension { + id: LogicalId::try_new("extension mount", id)?.into_string(), + }) + } + + pub fn kind_label(&self) -> &'static str { + match self { + Self::Command { .. } => "command", + Self::Projector { .. } => "projector", + Self::Surface { .. } => "surface", + Self::Extension { .. } => "extension", + } + } + + pub fn id(&self) -> &str { + match self { + Self::Command { id } + | Self::Projector { id } + | Self::Surface { id } + | Self::Extension { id } => id.as_str(), + } + } +} + +/// Named convenience presets that lower to ordinary mounts. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessPreset { + /// Every command, projector, surface, and extension in the manifest. + Full, + /// Every command mount (writers only). + Writer, + /// Every projection mount. + Projector, + /// Every query/live surface. + QueryApi, +} + +impl ProcessPreset { + /// Expand a preset into explicit mounts against one application manifest. + pub fn expand(self, manifest: &ApplicationManifest) -> ApplicationResult> { + let mut mounts = match self { + Self::Full => { + let mut mounts = Vec::new(); + for command in &manifest.commands { + mounts.push(MountSelector::command(command.id.clone())?); + } + for projection in &manifest.projections { + mounts.push(MountSelector::projector(projection.id.clone())?); + } + for surface in &manifest.surfaces { + mounts.push(MountSelector::surface(surface.id.clone())?); + } + for extension in &manifest.extensions { + mounts.push(MountSelector::extension(extension.id.clone())?); + } + mounts + } + Self::Writer => manifest + .commands + .iter() + .map(|command| MountSelector::command(command.id.clone())) + .collect::>>()?, + Self::Projector => manifest + .projections + .iter() + .map(|projection| MountSelector::projector(projection.id.clone())) + .collect::>>()?, + Self::QueryApi => manifest + .surfaces + .iter() + .map(|surface| MountSelector::surface(surface.id.clone())) + .collect::>>()?, + }; + mounts.sort(); + mounts.dedup(); + Ok(mounts) + } +} + +/// Validate that every selector references an identity present in the manifest. +pub fn validate_mounts_against_manifest( + manifest: &ApplicationManifest, + mounts: &[MountSelector], +) -> ApplicationResult<()> { + let mut seen = std::collections::BTreeSet::new(); + for mount in mounts { + if !seen.insert(mount.clone()) { + return Err(ApplicationError::Duplicate { + kind: "mount selector", + identity: format!("{}:{}", mount.kind_label(), mount.id()), + }); + } + match mount { + MountSelector::Command { id } => { + if !manifest.commands.iter().any(|command| command.id == *id) { + return Err(ApplicationError::Missing { + kind: "command mount", + identity: id.clone(), + }); + } + } + MountSelector::Projector { id } => { + if !manifest + .projections + .iter() + .any(|projection| projection.id == *id) + { + return Err(ApplicationError::Missing { + kind: "projector mount", + identity: id.clone(), + }); + } + } + MountSelector::Surface { id } => { + if !manifest.surfaces.iter().any(|surface| surface.id == *id) { + return Err(ApplicationError::Missing { + kind: "surface mount", + identity: id.clone(), + }); + } + } + MountSelector::Extension { id } => { + if !manifest + .extensions + .iter() + .any(|extension| extension.id == *id) + { + return Err(ApplicationError::Missing { + kind: "extension mount", + identity: id.clone(), + }); + } + } + } + } + Ok(()) +} diff --git a/src/application/plan.rs b/src/application/plan.rs new file mode 100644 index 00000000..07f4af6c --- /dev/null +++ b/src/application/plan.rs @@ -0,0 +1,480 @@ +//! Validated deployment plan compiler. +//! +//! Compiles an [`ApplicationManifest`] plus explicit process placement into a +//! deterministic, serializable [`DeploymentPlan`]. No I/O, process startup, or +//! cluster types are introduced here. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use super::capability::{ + derive_process_capabilities, Capability, CapabilityRequirement, SchemaLifecycleRequirement, +}; +use super::error::{ApplicationError, ApplicationResult}; +use super::identity::{canonical_json, sha256_fingerprint, LogicalId}; +use super::manifest::{ApplicationManifest, APPLICATION_MANIFEST_SCHEMA_VERSION}; +use super::mount::{ + validate_mounts_against_manifest, MountSelector, ProcessPreset, +}; +use super::topology::{derive_topology, TopologyIntent}; +use crate::graphql::command_contract::CommandConsistency; + +/// Wire schema version for deployment plans. +pub const DEPLOYMENT_PLAN_SCHEMA_VERSION: u32 = 1; +pub const MAX_DEPLOYMENT_PLAN_BYTES: usize = 1024 * 1024; +pub const MAX_PLAN_PROCESSES: usize = 256; +pub const MAX_PLAN_MOUNTS_PER_PROCESS: usize = 4096; + +/// One process entry in a deployment plan. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProcessPlan { + pub id: String, + /// Mounts selected into this process (sorted, unique). + pub mounts: Vec, + /// When true, command execution is remote for surfaces that need dispatch. + /// Local command mounts still imply local execution when present. + #[serde(default)] + pub remote_commands: bool, + pub capabilities: Vec, + pub topology: Vec, +} + +/// Complete validated deployment plan linked to an application manifest. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DeploymentPlan { + pub schema_version: u32, + pub name: String, + /// Application name from the source manifest. + pub application: String, + /// Logical fingerprint of the predecessor application manifest. + pub application_manifest_logical: String, + /// Canonical fingerprint of the predecessor application manifest. + pub application_manifest_canonical: String, + pub processes: Vec, + /// Union of process capabilities with reasons preserved. + pub capabilities: Vec, + pub schema_lifecycle: SchemaLifecycleRequirement, + pub fingerprints: PlanFingerprint, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PlanFingerprint { + pub logical: String, + pub canonical: String, +} + +/// Builder for one process before plan compilation. +#[derive(Clone, Debug)] +pub struct ProcessIntent { + pub id: String, + pub mounts: Vec, + pub remote_commands: bool, +} + +impl ProcessIntent { + pub fn new(id: impl Into) -> ApplicationResult { + Ok(Self { + id: LogicalId::try_new("process", id)?.into_string(), + mounts: Vec::new(), + remote_commands: false, + }) + } + + pub fn with_preset( + id: impl Into, + manifest: &ApplicationManifest, + preset: ProcessPreset, + ) -> ApplicationResult { + let mut process = Self::new(id)?; + process.mounts = preset.expand(manifest)?; + Ok(process) + } + + pub fn mounts(mut self, mounts: impl IntoIterator) -> Self { + self.mounts.extend(mounts); + self + } + + pub fn remote_commands(mut self, remote: bool) -> Self { + self.remote_commands = remote; + self + } +} + +/// Compile explicit process intents against one application manifest. +pub fn compile_deployment_plan( + name: impl Into, + manifest: &ApplicationManifest, + processes: impl IntoIterator, +) -> ApplicationResult { + manifest.validate()?; + let name = LogicalId::try_new("deployment plan", name)?.into_string(); + let mut processes = processes.into_iter().collect::>(); + if processes.is_empty() { + // Zero-config default: one full local process. + processes.push(ProcessIntent::with_preset( + "full", + manifest, + ProcessPreset::Full, + )?); + } + if processes.len() > MAX_PLAN_PROCESSES { + return Err(ApplicationError::InvalidSpec(format!( + "deployment plan exceeds max process count {MAX_PLAN_PROCESSES}" + ))); + } + + let mut process_ids = BTreeSet::new(); + let mut compiled = Vec::new(); + let mut global_capabilities: BTreeMap> = + BTreeMap::new(); + let mut schema_reasons = BTreeSet::new(); + let mut schema_owner: Option = None; + + // Track command and direct-projection placement for Atomic collocation. + let mut command_hosts: BTreeMap> = BTreeMap::new(); + let mut direct_projection_hosts: BTreeMap> = BTreeMap::new(); + let mut command_to_direct: BTreeMap> = BTreeMap::new(); + + for command in &manifest.commands { + if matches!(command.consistency, CommandConsistency::Atomic) { + // Direct projection targets from projected_model / direct_projection proof. + if let Some(model) = &command.projected_model { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(model.clone()); + } + // Also match direct projection specs that share the command's projected model. + for projection in &manifest.projections { + if projection.direct { + if let Some(model) = &command.projected_model { + if projection.models.iter().any(|m| m == model) { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(projection.id.clone()); + } + } + // If the command lists direct_projection material, treat all + // direct projectors as potential collocation partners when + // the projected_model is unset but atomic. + if command.projected_model.is_none() && command.direct_projection.is_some() { + command_to_direct + .entry(command.id.clone()) + .or_default() + .insert(projection.id.clone()); + } + } + } + } + } + + for mut process in processes { + if !process_ids.insert(process.id.clone()) { + return Err(ApplicationError::Duplicate { + kind: "process", + identity: process.id, + }); + } + process.mounts.sort(); + process.mounts.dedup(); + if process.mounts.len() > MAX_PLAN_MOUNTS_PER_PROCESS { + return Err(ApplicationError::InvalidSpec(format!( + "process `{}` exceeds max mount count {MAX_PLAN_MOUNTS_PER_PROCESS}", + process.id + ))); + } + validate_mounts_against_manifest(manifest, &process.mounts)?; + + // Local command mounts force local execution regardless of remote flag. + let has_local_commands = process + .mounts + .iter() + .any(|mount| matches!(mount, MountSelector::Command { .. })); + let remote_commands = process.remote_commands && !has_local_commands; + + for mount in &process.mounts { + match mount { + MountSelector::Command { id } => { + command_hosts + .entry(id.clone()) + .or_default() + .insert(process.id.clone()); + } + MountSelector::Projector { id } => { + if let Some(projection) = + manifest.projections.iter().find(|projection| projection.id == *id) + { + if projection.direct { + direct_projection_hosts + .entry(id.clone()) + .or_default() + .insert(process.id.clone()); + } + } + } + _ => {} + } + } + + let (capabilities, schema) = + derive_process_capabilities(manifest, &process.id, &process.mounts, remote_commands)?; + for requirement in &capabilities { + global_capabilities + .entry(requirement.capability) + .or_default() + .extend(requirement.reasons.iter().cloned()); + } + for reason in schema.reasons { + schema_reasons.insert(reason); + } + if let Some(owner) = schema.logical_owner { + match &schema_owner { + Some(existing) if existing != &owner => { + return Err(ApplicationError::Collision { + kind: "schema lifecycle owner", + identity: owner, + reason: format!("conflicts with existing owner `{existing}`"), + }); + } + None => schema_owner = Some(owner), + _ => {} + } + } + + let topology = derive_topology(manifest, &process.id, &process.mounts, remote_commands)?; + compiled.push(ProcessPlan { + id: process.id, + mounts: process.mounts, + remote_commands, + capabilities, + topology, + }); + } + + // Atomic collocation: each Atomic command and its direct projection mounts + // must share at least one process. Eventual splits are always allowed. + for (command_id, direct_ids) in &command_to_direct { + let Some(command_processes) = command_hosts.get(command_id) else { + // Atomic command not mounted anywhere is fine for pure API plans. + continue; + }; + for direct_id in direct_ids { + // direct_id may be a model name or projection id — match projection hosts. + let projection_hosts = direct_projection_hosts.get(direct_id); + let model_hosts: Option> = { + // If direct_id is a model, find direct projectors targeting it. + let mut hosts = BTreeSet::new(); + for projection in &manifest.projections { + if projection.direct && projection.models.iter().any(|m| m == direct_id) { + if let Some(process_ids) = direct_projection_hosts.get(&projection.id) { + hosts.extend(process_ids.iter().cloned()); + } + } + } + if hosts.is_empty() { + None + } else { + Some(hosts) + } + }; + let hosts = match (projection_hosts, model_hosts) { + (Some(a), Some(b)) => a.union(&b).cloned().collect::>(), + (Some(a), None) => a.clone(), + (None, Some(b)) => b, + (None, None) => { + // No direct projector mounted for this atomic command — fail. + return Err(ApplicationError::InvalidSpec(format!( + "atomic command `{command_id}` is mounted without collocated direct projection `{direct_id}`" + ))); + } + }; + let shared = command_processes + .intersection(&hosts) + .cloned() + .collect::>(); + if shared.is_empty() { + return Err(ApplicationError::InvalidSpec(format!( + "atomic command `{command_id}` must be collocated with direct projection `{direct_id}`; command processes {:?}, projection processes {:?}", + command_processes, hosts + ))); + } + } + } + + // Reject duplicate command ownership across processes when both claim local execution. + for (command_id, hosts) in &command_hosts { + if hosts.len() > 1 { + return Err(ApplicationError::Collision { + kind: "command mount", + identity: command_id.clone(), + reason: format!( + "selected in multiple processes: {}", + hosts.iter().cloned().collect::>().join(", ") + ), + }); + } + } + + compiled.sort_by(|left, right| left.id.cmp(&right.id)); + + let capabilities = global_capabilities + .into_iter() + .map(|(capability, mut reasons)| { + reasons.sort(); + reasons.dedup(); + CapabilityRequirement { + capability, + reasons, + } + }) + .collect::>(); + + let schema_lifecycle = SchemaLifecycleRequirement { + required: !schema_reasons.is_empty(), + logical_owner: schema_owner, + reasons: schema_reasons.into_iter().collect(), + }; + + let mut plan = DeploymentPlan { + schema_version: DEPLOYMENT_PLAN_SCHEMA_VERSION, + name, + application: manifest.name.clone(), + application_manifest_logical: manifest.fingerprints.logical.clone(), + application_manifest_canonical: manifest.fingerprints.canonical.clone(), + processes: compiled, + capabilities, + schema_lifecycle, + fingerprints: PlanFingerprint::default(), + }; + plan.refresh_fingerprints()?; + plan.validate()?; + Ok(plan) +} + +impl DeploymentPlan { + pub fn refresh_fingerprints(&mut self) -> ApplicationResult<()> { + self.fingerprints = expected_fingerprints(self)?; + Ok(()) + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + let mut value = serde_json::to_value(self)?; + if let serde_json::Value::Object(fields) = &mut value { + // Fingerprints are derived; zero them for logical identity then re-encode full. + fields.insert( + "fingerprints".into(), + serde_json::json!({ "logical": "", "canonical": "" }), + ); + } + let logical = canonical_json(&value); + Ok(serde_json::to_vec(&logical)?) + } + + pub fn encode(&self) -> ApplicationResult> { + self.validate()?; + let bytes = serde_json::to_vec(&canonical_json(&serde_json::to_value(self)?))?; + if bytes.len() > MAX_DEPLOYMENT_PLAN_BYTES { + return Err(ApplicationError::InvalidSpec(format!( + "deployment plan exceeds max bytes {MAX_DEPLOYMENT_PLAN_BYTES}" + ))); + } + Ok(bytes) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> ApplicationResult { + if bytes.len() > MAX_DEPLOYMENT_PLAN_BYTES { + return Err(ApplicationError::InvalidSpec( + "deployment plan bytes exceed maximum".into(), + )); + } + let plan: Self = serde_json::from_slice(bytes)?; + plan.validate()?; + let reencoded = plan.encode()?; + if reencoded != bytes { + return Err(ApplicationError::NonCanonical("deployment plan")); + } + Ok(plan) + } + + pub fn validate(&self) -> ApplicationResult<()> { + if self.schema_version != DEPLOYMENT_PLAN_SCHEMA_VERSION { + return Err(ApplicationError::UnsupportedVersion { + expected: DEPLOYMENT_PLAN_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + LogicalId::try_new("deployment plan", self.name.clone())?; + LogicalId::try_new("application", self.application.clone())?; + if self.application_manifest_logical.is_empty() + || self.application_manifest_canonical.is_empty() + { + return Err(ApplicationError::InvalidSpec( + "deployment plan requires application manifest predecessor fingerprints".into(), + )); + } + if self.processes.is_empty() { + return Err(ApplicationError::InvalidSpec( + "deployment plan must declare at least one process".into(), + )); + } + let expected = expected_fingerprints(self)?; + if self.fingerprints != expected { + return Err(ApplicationError::NonCanonical("deployment plan fingerprints")); + } + // Silence unused import when APPLICATION_MANIFEST_SCHEMA_VERSION is only + // for documentation linkage in validate paths. + let _ = APPLICATION_MANIFEST_SCHEMA_VERSION; + Ok(()) + } + + /// Pure inspection data for CLI/runtime consumers. + pub fn describe(&self) -> serde_json::Value { + serde_json::json!({ + "name": self.name, + "application": self.application, + "processes": self.processes.iter().map(|process| { + serde_json::json!({ + "id": process.id, + "mounts": process.mounts, + "remote_commands": process.remote_commands, + "capability_count": process.capabilities.len(), + "topology_count": process.topology.len(), + }) + }).collect::>(), + "capabilities": self.capabilities.iter().map(|cap| { + serde_json::json!({ + "capability": cap.capability.as_str(), + "reason_count": cap.reasons.len(), + }) + }).collect::>(), + "schema_lifecycle": self.schema_lifecycle, + "application_manifest_logical": self.application_manifest_logical, + "application_manifest_canonical": self.application_manifest_canonical, + "fingerprints": self.fingerprints, + }) + } +} + +fn expected_fingerprints(plan: &DeploymentPlan) -> ApplicationResult { + let mut for_logical = plan.clone(); + for_logical.fingerprints = PlanFingerprint::default(); + let logical_value = canonical_json(&serde_json::to_value(&for_logical)?); + let logical_bytes = serde_json::to_vec(&logical_value)?; + let logical = sha256_fingerprint(&logical_bytes); + + let mut for_canonical = plan.clone(); + for_canonical.fingerprints = PlanFingerprint { + logical: logical.clone(), + canonical: String::new(), + }; + let canonical_value = canonical_json(&serde_json::to_value(&for_canonical)?); + let canonical_bytes = serde_json::to_vec(&canonical_value)?; + let canonical = sha256_fingerprint(&canonical_bytes); + Ok(PlanFingerprint { logical, canonical }) +} diff --git a/src/application/registration.rs b/src/application/registration.rs new file mode 100644 index 00000000..afffdeeb --- /dev/null +++ b/src/application/registration.rs @@ -0,0 +1,321 @@ +use std::sync::Arc; + +use super::error::ApplicationResult; +use super::manifest::{ApplicationExtension, ApplicationManifest, ManifestProvenance}; +use super::module::{Module, SurfaceSpec}; +use crate::graphql::surface::Surface; +use crate::graphql::{ClientManifestError, DistributedClientManifest, DistributedClientSurfaceExport}; + +/// Explicit application registration. No linker inventory or source scan is +/// consulted; only the values supplied to this constructor participate. +pub struct Application { + name: String, + modules: Vec, + surfaces: Vec, + manifest: ApplicationManifest, +} + +impl Clone for Application { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + modules: self.modules.clone(), + surfaces: self.surfaces.clone(), + manifest: self.manifest.clone(), + } + } +} + +impl std::fmt::Debug for Application { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Application") + .field("name", &self.name) + .field( + "modules", + &self.modules.iter().map(Module::id).collect::>(), + ) + .field("surfaces", &self.surfaces.len()) + .finish() + } +} + +impl Application { + pub fn new(name: impl Into) -> ApplicationBuilder { + ApplicationBuilder::new(name) + } + + pub fn try_new( + name: impl Into, + modules: impl IntoIterator, + surfaces: impl IntoIterator, + ) -> ApplicationResult { + let modules = modules.into_iter().collect::>(); + let surfaces = surfaces.into_iter().collect::>(); + let manifest = + ApplicationManifest::try_from_modules(name, modules.clone(), surfaces.clone())?; + Ok(Self { + name: manifest.name.clone(), + modules, + surfaces, + manifest, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn modules(&self) -> &[Module] { + &self.modules + } + + pub fn surfaces(&self) -> &[SurfaceSpec] { + &self.surfaces + } + + pub fn manifest(&self) -> &ApplicationManifest { + &self.manifest + } + + pub fn canonical_bytes(&self) -> ApplicationResult> { + self.manifest.canonical_bytes() + } + + pub fn fingerprint(&self) -> ApplicationResult { + self.manifest.fingerprint() + } +} + +/// Fluent explicit application authoring API. +pub struct ApplicationBuilder { + name: String, + modules: Vec, + surfaces: Vec, + required_capabilities: Vec, + extensions: Vec, + provenance: Option, +} + +impl ApplicationBuilder { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + modules: Vec::new(), + surfaces: Vec::new(), + required_capabilities: Vec::new(), + extensions: Vec::new(), + provenance: None, + } + } + + pub fn module(mut self, module: Module) -> Self { + self.modules.push(module); + self + } + + pub fn modules(mut self, modules: impl IntoIterator) -> Self { + self.modules.extend(modules); + self + } + + pub fn surface(mut self, surface: SurfaceSpec) -> Self { + self.surfaces.push(surface); + self + } + + pub fn surfaces(mut self, surfaces: impl IntoIterator) -> Self { + self.surfaces.extend(surfaces); + self + } + + pub fn required_capability(mut self, capability: impl Into) -> Self { + self.required_capabilities.push(capability.into()); + self + } + + pub fn required_capabilities( + mut self, + capabilities: impl IntoIterator>, + ) -> Self { + self.required_capabilities + .extend(capabilities.into_iter().map(Into::into)); + self + } + + pub fn extension(mut self, extension: ApplicationExtension) -> Self { + self.extensions.push(extension); + self + } + + pub fn extensions( + mut self, + extensions: impl IntoIterator, + ) -> Self { + self.extensions.extend(extensions); + self + } + + pub fn provenance(mut self, provenance: ManifestProvenance) -> Self { + self.provenance = Some(provenance); + self + } + + pub fn build(self) -> ApplicationResult { + let mut application = Application::try_new(self.name, self.modules, self.surfaces)?; + application + .manifest + .required_capabilities + .extend(self.required_capabilities); + application.manifest.required_capabilities.sort(); + application.manifest.required_capabilities.dedup(); + application.manifest.extensions.extend(self.extensions); + if let Some(provenance) = self.provenance { + application.manifest = application.manifest.with_provenance(provenance); + } + application.manifest.refresh_fingerprints()?; + Ok(application) + } +} + +/// Pure compiler entrypoint for contract-only packages. +pub struct ContractCompiler { + name: String, + modules: Vec, + surface: Option>, + surface_spec: Option, +} + +impl ContractCompiler { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + modules: Vec::new(), + surface: None, + surface_spec: None, + } + } + + pub fn modules(mut self, modules: impl IntoIterator) -> Self { + self.modules.extend(modules); + self + } + + /// Bind the concrete authoritative Surface once. Every compiler output + /// uses this exact value and its compiled `SurfaceSpec`; no table inventory + /// or caller-supplied unrelated Surface is accepted beside the manifest. + pub fn with_surface( + mut self, + id: impl Into, + surface: impl Into>, + ) -> Result { + let surface = surface.into(); + let spec = SurfaceSpec::from_surface(id, &surface).map_err(|error| error.to_string())?; + if let Some(existing) = &self.surface_spec { + if existing.id != spec.id || existing.fingerprint != spec.fingerprint { + return Err(format!( + "ContractCompiler already has a different authoritative Surface contract" + )); + } + return Err("ContractCompiler accepts exactly one authoritative Surface contract".into()); + } + self.surface = Some(surface); + self.surface_spec = Some(spec); + Ok(self) + } + + /// Construct a compiler around one authoritative Surface contract. + pub fn from_surface( + name: impl Into, + surface_id: impl Into, + surface: impl Into>, + ) -> Result { + Self::new(name).with_surface(surface_id, surface) + } + + /// Return the already-bound shared Surface IR. + pub fn surface(&self) -> Result { + self.surface + .as_deref() + .cloned() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".into()) + } + + /// Render SDL directly from the contract-only Surface IR. + pub fn graphql_sdl(&self) -> Result { + crate::graphql::graphql_sdl_from_surface( + self.surface + .as_deref() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".to_owned())?, + ) + } + + /// Compile the selected client artifact from the same Surface identity. + pub fn client_manifest(&self) -> Result { + let surface = self.surface.clone().ok_or_else(|| { + ClientManifestError("ContractCompiler requires one bound Surface contract".into()) + })?; + let expected = self.bound_surface_spec().map_err(ClientManifestError)?; + let export = DistributedClientSurfaceExport::from_contract(self.name.clone(), surface)?; + let actual = SurfaceSpec::from_surface(expected.id.clone(), export.surface().as_ref()) + .map_err(|error| ClientManifestError(error.to_string()))?; + if actual.id != expected.id || actual.fingerprint != expected.fingerprint { + return Err(ClientManifestError( + "client manifest Surface identity diverges from the compiler contract".into(), + )); + } + export.manifest() + } + + /// Compile the logical manifest without mounting a handler. + pub fn manifest(&self) -> ApplicationResult { + let manifest = ApplicationManifest::try_from_modules( + self.name.clone(), + self.modules.clone(), + self.surface_spec.clone().into_iter(), + )?; + let expected = self + .bound_surface_spec() + .map_err(crate::application::ApplicationError::InvalidSpec)?; + if manifest + .surfaces + .iter() + .find(|surface| surface.id == expected.id) + .is_none_or(|surface| surface.fingerprint != expected.fingerprint) + { + return Err(crate::application::ApplicationError::NonCanonical( + "compiler Surface identity", + )); + } + Ok(manifest) + } + + pub fn compile(&self) -> ApplicationResult { + let application = Application::try_new( + self.name.clone(), + self.modules.clone(), + self.surface_spec.clone().into_iter(), + )?; + let expected = self + .bound_surface_spec() + .map_err(crate::application::ApplicationError::InvalidSpec)?; + if application + .manifest() + .surfaces + .iter() + .find(|surface| surface.id == expected.id) + .is_none_or(|surface| surface.fingerprint != expected.fingerprint) + { + return Err(crate::application::ApplicationError::NonCanonical( + "compiler Surface identity", + )); + } + Ok(application) + } + + fn bound_surface_spec(&self) -> Result { + self.surface_spec + .clone() + .ok_or_else(|| "ContractCompiler requires one bound Surface contract".into()) + } +} diff --git a/src/application/runtime_host.rs b/src/application/runtime_host.rs new file mode 100644 index 00000000..e0ad550b --- /dev/null +++ b/src/application/runtime_host.rs @@ -0,0 +1,225 @@ +//! Framework runtime host skeleton (task 12). +//! +//! Realizes one process entry from a validated [`DeploymentPlan`] by requiring +//! the explained capability set and an explicit [`CommandDispatcher`]. Full +//! adapter bootstrap (stores, workers, supervision) continues to grow here; +//! application code must not hand-pair dialect runners. + +use super::capability::Capability; +use super::error::{ApplicationError, ApplicationResult}; +use super::plan::{DeploymentPlan, ProcessPlan}; +use crate::command_dispatch::SharedCommandDispatcher; +use std::collections::BTreeSet; + +/// Provider-backed capabilities available to the host at bind time. +#[derive(Clone, Debug, Default)] +pub struct CapabilityProviders { + pub available: BTreeSet, +} + +impl CapabilityProviders { + pub fn with(mut self, capability: Capability) -> Self { + self.available.insert(capability); + self + } + + pub fn contains(&self, capability: Capability) -> bool { + self.available.contains(&capability) + } +} + +/// One bound process ready for supervision / serve (task 12 expansion point). +pub struct RuntimeHost { + pub plan_name: String, + pub process: ProcessPlan, + pub dispatcher: Option, + pub providers: CapabilityProviders, +} + +impl RuntimeHost { + /// Bind one process from a validated plan against available providers. + /// + /// Missing required capabilities fail closed before serve. + pub fn bind( + plan: &DeploymentPlan, + process_id: &str, + providers: CapabilityProviders, + dispatcher: Option, + ) -> ApplicationResult { + plan.validate()?; + let process = plan + .processes + .iter() + .find(|process| process.id == process_id) + .cloned() + .ok_or_else(|| ApplicationError::Missing { + kind: "process", + identity: process_id.to_string(), + })?; + + for requirement in &process.capabilities { + if !providers.contains(requirement.capability) { + return Err(ApplicationError::InvalidSpec(format!( + "process `{process_id}` requires capability `{}` but no provider is bound", + requirement.capability.as_str() + ))); + } + } + + let needs_dispatch = process.mounts.iter().any(|mount| { + matches!( + mount, + super::mount::MountSelector::Command { .. } + | super::mount::MountSelector::Surface { .. } + ) + }); + if needs_dispatch && dispatcher.is_none() { + return Err(ApplicationError::InvalidSpec(format!( + "process `{process_id}` requires a CommandDispatcher for command/surface mounts" + ))); + } + + Ok(Self { + plan_name: plan.name.clone(), + process, + dispatcher, + providers, + }) + } + + pub fn process_id(&self) -> &str { + &self.process.id + } + + pub fn dispatcher(&self) -> Option<&SharedCommandDispatcher> { + self.dispatcher.as_ref() + } +} + +/// Convenience: bind the sole process when a plan has exactly one entry. +pub fn bind_single_process( + plan: &DeploymentPlan, + providers: CapabilityProviders, + dispatcher: Option, +) -> ApplicationResult { + if plan.processes.len() != 1 { + return Err(ApplicationError::InvalidSpec( + "bind_single_process requires exactly one process in the plan".into(), + )); + } + RuntimeHost::bind(plan, &plan.processes[0].id, providers, dispatcher) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::{ + compile_deployment_plan, Application, CommandDefinition, CommandSpec, CommandTypeSpec, + ModelFieldSpec, ModelSpec, Module, ProcessIntent, ProcessPreset, ProjectionSpec, + }; + use crate::graphql::CommandConsistency; + use std::sync::Arc; + + fn manifest() -> crate::application::ApplicationManifest { + let model = ModelSpec::try_new( + "TodoView", + "todos", + [ModelFieldSpec { + name: "todo_id".into(), + scalar: "String".into(), + nullable: false, + }], + ["todo_id"], + ) + .unwrap(); + let command = CommandSpec::try_new( + "todo.create", + "todo_create", + CommandTypeSpec { + name: "In".into(), + fields: vec![], + }, + CommandTypeSpec { + name: "Out".into(), + fields: vec![], + }, + CommandConsistency::Eventual, + ) + .unwrap(); + let module = Module::new("todo") + .command_definitions([CommandDefinition::contract(command)]) + .models([model]) + .projections([ + ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(), + ]) + .build() + .unwrap(); + Application::new("todo-app") + .module(module) + .build() + .unwrap() + .manifest() + .clone() + } + + #[test] + fn host_fails_closed_without_required_capability() { + let manifest = manifest(); + let plan = compile_deployment_plan( + "local", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Writer).unwrap()], + ) + .unwrap(); + let err = match RuntimeHost::bind(&plan, "full", CapabilityProviders::default(), None) { + Ok(_) => panic!("expected missing capability failure"), + Err(error) => error, + }; + assert!(err.to_string().contains("capability")); + } + + #[test] + fn host_binds_when_capabilities_and_dispatcher_present() { + use crate::command_dispatch::{CommandDispatchError, CommandDispatcher}; + use crate::microsvc::{CommandRequest, CommandResponse}; + use async_trait::async_trait; + + struct Stub; + #[async_trait] + impl CommandDispatcher for Stub { + async fn dispatch( + &self, + _request: &CommandRequest, + ) -> Result { + Ok(CommandResponse { + status: 200, + body: serde_json::json!({}), + }) + } + fn kind(&self) -> &'static str { + "stub" + } + } + + let manifest = manifest(); + let plan = compile_deployment_plan( + "local", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Writer).unwrap()], + ) + .unwrap(); + let mut providers = CapabilityProviders::default(); + for requirement in &plan.processes[0].capabilities { + providers = providers.with(requirement.capability); + } + let host = RuntimeHost::bind( + &plan, + "full", + providers, + Some(Arc::new(Stub) as SharedCommandDispatcher), + ) + .unwrap(); + assert_eq!(host.process_id(), "full"); + assert!(host.dispatcher().is_some()); + } +} diff --git a/src/application/topology.rs b/src/application/topology.rs new file mode 100644 index 00000000..efae1099 --- /dev/null +++ b/src/application/topology.rs @@ -0,0 +1,112 @@ +//! Derived logical topology intent from a process mount selection. +//! +//! Physical worker routes, epochs, and subscriptions are framework-owned later +//! outputs. This module records only the logical intent inventory that +//! downstream runtime and renderers consume without reinterpreting mounts. + +use serde::{Deserialize, Serialize}; + +use super::error::{ApplicationError, ApplicationResult}; +use super::manifest::ApplicationManifest; +use super::mount::MountSelector; + +/// One derived logical route or subscription intent. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum TopologyIntent { + CommandRoute { + command_id: String, + process_id: String, + remote: bool, + }, + ProjectionSubscription { + projection_id: String, + process_id: String, + direct: bool, + facts: Vec, + }, + SurfaceEndpoint { + surface_id: String, + process_id: String, + }, + ExtensionHook { + extension_id: String, + process_id: String, + }, +} + +/// Derive sorted topology intents for one process. +pub fn derive_topology( + manifest: &ApplicationManifest, + process_id: &str, + mounts: &[MountSelector], + remote_commands: bool, +) -> ApplicationResult> { + let mut intents = Vec::new(); + for mount in mounts { + match mount { + MountSelector::Command { id } => { + if !manifest.commands.iter().any(|command| command.id == *id) { + return Err(ApplicationError::Missing { + kind: "command", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::CommandRoute { + command_id: id.clone(), + process_id: process_id.to_string(), + remote: remote_commands, + }); + } + MountSelector::Projector { id } => { + let projection = manifest + .projections + .iter() + .find(|projection| projection.id == *id) + .ok_or_else(|| ApplicationError::Missing { + kind: "projection", + identity: id.clone(), + })?; + let mut facts = projection.facts.clone(); + facts.sort(); + facts.dedup(); + intents.push(TopologyIntent::ProjectionSubscription { + projection_id: id.clone(), + process_id: process_id.to_string(), + direct: projection.direct, + facts, + }); + } + MountSelector::Surface { id } => { + if !manifest.surfaces.iter().any(|surface| surface.id == *id) { + return Err(ApplicationError::Missing { + kind: "surface", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::SurfaceEndpoint { + surface_id: id.clone(), + process_id: process_id.to_string(), + }); + } + MountSelector::Extension { id } => { + if !manifest + .extensions + .iter() + .any(|extension| extension.id == *id) + { + return Err(ApplicationError::Missing { + kind: "extension", + identity: id.clone(), + }); + } + intents.push(TopologyIntent::ExtensionHook { + extension_id: id.clone(), + process_id: process_id.to_string(), + }); + } + } + } + intents.sort(); + Ok(intents) +} diff --git a/src/command_dispatch/envelope.rs b/src/command_dispatch/envelope.rs new file mode 100644 index 00000000..6b36e3f5 --- /dev/null +++ b/src/command_dispatch/envelope.rs @@ -0,0 +1,98 @@ +//! Versioned command dispatch envelope shared by local and remote adapters. +//! +//! Wire fields beyond the existing [`crate::microsvc::CommandRequest`] are +//! additive and match the task-20 approved remote profile. + +use crate::microsvc::{CommandRequest, CommandResponse}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Envelope schema version for local/remote semantic equality. +pub const COMMAND_DISPATCH_ENVELOPE_VERSION: u32 = 1; + +/// Versioned dispatch envelope. Local and remote adapters share this shape. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandDispatchEnvelope { + pub version: u32, + pub command: String, + pub input: serde_json::Value, + /// Verified identity claims reconstructed at the writer boundary. + #[serde(default)] + pub session_variables: BTreeMap, + /// Stable command contract fingerprint when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_fingerprint: Option, + /// Client-supplied idempotency key for ledger dedup. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + /// Causation identifier linking related work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + /// Deadline as unix millis; remote adapters must fail closed when exceeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_unix_ms: Option, +} + +impl CommandDispatchEnvelope { + pub fn from_request(request: &CommandRequest) -> Self { + let mut session_variables = BTreeMap::new(); + for (key, value) in &request.session_variables { + session_variables.insert(key.clone(), value.clone()); + } + Self { + version: COMMAND_DISPATCH_ENVELOPE_VERSION, + command: request.command.clone(), + input: request.input.clone(), + session_variables, + command_fingerprint: None, + idempotency_key: None, + causation_id: None, + deadline_unix_ms: None, + } + } + + pub fn into_request(self) -> Result { + if self.version != COMMAND_DISPATCH_ENVELOPE_VERSION { + return Err(format!( + "unsupported command dispatch envelope version {}", + self.version + )); + } + Ok(CommandRequest { + command: self.command, + input: self.input, + session_variables: self.session_variables.into_iter().collect(), + }) + } + + pub fn canonical_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } +} + +/// Durable receipt returned alongside a successful or rejected dispatch. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CommandDispatchReceipt { + pub command: String, + pub status: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ledger_id: Option, +} + +impl CommandDispatchReceipt { + pub fn from_response(command: &str, response: &CommandResponse) -> Self { + Self { + command: command.to_string(), + status: response.status, + causation_id: None, + idempotency_key: None, + ledger_id: None, + } + } +} diff --git a/src/command_dispatch/error.rs b/src/command_dispatch/error.rs new file mode 100644 index 00000000..73f17f0f --- /dev/null +++ b/src/command_dispatch/error.rs @@ -0,0 +1,30 @@ +use std::fmt; + +/// Fail-closed dispatch errors shared by local and remote adapters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommandDispatchError { + /// Envelope version/codec/fingerprint rejected before execution. + Rejected(String), + /// Destination unknown or ambiguous for the selected process plan. + Unroutable(String), + /// Remote transport or trust failure (no ambiguous success). + Transport(String), + /// Deadline exceeded before a durable outcome was known. + DeadlineExceeded, + /// Handler/domain failure mapped through the approved contract. + Handler(String), +} + +impl fmt::Display for CommandDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Rejected(reason) => write!(formatter, "command rejected: {reason}"), + Self::Unroutable(reason) => write!(formatter, "command unroutable: {reason}"), + Self::Transport(reason) => write!(formatter, "command transport error: {reason}"), + Self::DeadlineExceeded => write!(formatter, "command dispatch deadline exceeded"), + Self::Handler(reason) => write!(formatter, "command handler error: {reason}"), + } + } +} + +impl std::error::Error for CommandDispatchError {} diff --git a/src/command_dispatch/local.rs b/src/command_dispatch/local.rs new file mode 100644 index 00000000..57632024 --- /dev/null +++ b/src/command_dispatch/local.rs @@ -0,0 +1,35 @@ +//! Local dispatcher adapter over an in-process [`Service`]. + +use super::{CommandDispatchError, CommandDispatcher}; +use crate::microsvc::{CommandRequest, CommandResponse, Service}; +use async_trait::async_trait; +use std::sync::Arc; + +/// Dispatches commands to a local executable [`Service`]. +pub struct LocalCommandDispatcher { + service: Arc, +} + +impl LocalCommandDispatcher { + pub fn new(service: Arc) -> Self { + Self { service } + } + + pub fn service(&self) -> &Arc { + &self.service + } +} + +#[async_trait] +impl CommandDispatcher for LocalCommandDispatcher { + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result { + Ok(self.service.dispatch_request(request).await) + } + + fn kind(&self) -> &'static str { + "local" + } +} diff --git a/src/command_dispatch/mod.rs b/src/command_dispatch/mod.rs new file mode 100644 index 00000000..2fbffe3d --- /dev/null +++ b/src/command_dispatch/mod.rs @@ -0,0 +1,39 @@ +//! Versioned command dispatch boundary for local and remote execution. +//! +//! GraphQL mutation execution and process hosts depend on +//! [`CommandDispatcher`] rather than a concrete `Service`. Schema and client +//! compilation never construct a dispatcher. + +mod envelope; +mod error; +mod local; +mod remote; + +pub use envelope::{ + CommandDispatchEnvelope, CommandDispatchReceipt, COMMAND_DISPATCH_ENVELOPE_VERSION, +}; +pub use error::CommandDispatchError; +pub use local::LocalCommandDispatcher; +pub use remote::{ + RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, APPROVED_REMOTE_DISPATCH_PROFILE, +}; + +use crate::microsvc::{CommandRequest, CommandResponse}; +use async_trait::async_trait; +use std::sync::Arc; + +/// Object-safe async command dispatcher shared by GraphQL and process hosts. +#[async_trait] +pub trait CommandDispatcher: Send + Sync { + /// Dispatch one versioned command request and return the durable response. + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result; + + /// Human-stable dispatcher kind for inspection/metrics. + fn kind(&self) -> &'static str; +} + +/// Shared handle used by GraphQL engines and runtimes. +pub type SharedCommandDispatcher = Arc; diff --git a/src/command_dispatch/remote.rs b/src/command_dispatch/remote.rs new file mode 100644 index 00000000..89bdb68a --- /dev/null +++ b/src/command_dispatch/remote.rs @@ -0,0 +1,274 @@ +//! Remote command dispatch adapter (task 20 approved profile). +//! +//! Transport, trust, credential, and replay rules are fixed by +//! [`APPROVED_REMOTE_DISPATCH_PROFILE`]. This module implements only that +//! approved contract — it does not invent alternate security modes. + +use super::envelope::{CommandDispatchEnvelope, COMMAND_DISPATCH_ENVELOPE_VERSION}; +use super::{CommandDispatchError, CommandDispatcher}; +use crate::microsvc::{CommandRequest, CommandResponse}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Stable identifier for the single production remote profile approved by +/// task 20. Implementation and tests must cite this constant. +pub const APPROVED_REMOTE_DISPATCH_PROFILE: &str = + "distributed.command_dispatch.remote.v1.https_mtls_service_identity"; + +/// Trust mode fixed by the approved remote profile. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteTrustMode { + /// Mutual TLS with workload/service identity (approved production mode). + MutualTlsServiceIdentity, +} + +/// Configuration for the approved remote adapter. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RemoteDispatchConfig { + /// Logical destination id from the deployment plan topology. + pub destination: String, + /// Absolute HTTPS endpoint for the writer process. + pub endpoint: String, + /// Trust mode — only the approved production mode is accepted. + pub trust: RemoteTrustMode, + /// Maximum request body bytes. + pub max_body_bytes: usize, + /// Request timeout. + pub timeout_ms: u64, +} + +impl RemoteDispatchConfig { + pub fn validate(&self) -> Result<(), CommandDispatchError> { + if self.destination.trim().is_empty() { + return Err(CommandDispatchError::Unroutable( + "remote destination must not be empty".into(), + )); + } + if !self.endpoint.starts_with("https://") { + return Err(CommandDispatchError::Rejected( + "approved remote profile requires https endpoints".into(), + )); + } + if !matches!(self.trust, RemoteTrustMode::MutualTlsServiceIdentity) { + return Err(CommandDispatchError::Rejected( + "unsupported remote trust mode".into(), + )); + } + if self.max_body_bytes == 0 || self.timeout_ms == 0 { + return Err(CommandDispatchError::Rejected( + "remote max_body_bytes and timeout_ms must be positive".into(), + )); + } + Ok(()) + } +} + +/// HTTP client abstraction so tests can loopback without a network. +#[async_trait] +pub trait RemoteCommandTransport: Send + Sync { + async fn post_json( + &self, + endpoint: &str, + body: &[u8], + headers: BTreeMap, + ) -> Result<(u16, Vec), CommandDispatchError>; +} + +/// Remote dispatcher that encodes the approved envelope and posts it. +pub struct RemoteCommandDispatcher { + config: RemoteDispatchConfig, + transport: Arc, +} + +impl RemoteCommandDispatcher { + pub fn new( + config: RemoteDispatchConfig, + transport: Arc, + ) -> Result { + config.validate()?; + Ok(Self { config, transport }) + } + + pub fn config(&self) -> &RemoteDispatchConfig { + &self.config + } + + pub fn profile(&self) -> &'static str { + APPROVED_REMOTE_DISPATCH_PROFILE + } +} + +#[async_trait] +impl CommandDispatcher for RemoteCommandDispatcher { + async fn dispatch( + &self, + request: &CommandRequest, + ) -> Result { + let mut envelope = CommandDispatchEnvelope::from_request(request); + envelope.version = COMMAND_DISPATCH_ENVELOPE_VERSION; + // Never accept caller-supplied roles as trusted identity across the wire. + // The approved profile requires the writer to reconstruct identity from + // mTLS service identity + framework adapters. Session variables that + // look like forwarded roles are stripped here. + envelope.session_variables.retain(|key, _| { + let lower = key.to_ascii_lowercase(); + !(lower == "x-roles" || lower == "roles" || lower.ends_with("-roles")) + }); + + let body = envelope + .canonical_bytes() + .map_err(|error| CommandDispatchError::Rejected(error.to_string()))?; + if body.len() > self.config.max_body_bytes { + return Err(CommandDispatchError::Rejected(format!( + "remote command body exceeds max_body_bytes {}", + self.config.max_body_bytes + ))); + } + + if let Some(deadline) = envelope.deadline_unix_ms { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_millis() as u64; + if now > deadline { + return Err(CommandDispatchError::DeadlineExceeded); + } + } + + let mut headers = BTreeMap::new(); + headers.insert( + "content-type".into(), + "application/json".into(), + ); + headers.insert( + "x-distributed-dispatch-profile".into(), + APPROVED_REMOTE_DISPATCH_PROFILE.into(), + ); + headers.insert( + "x-distributed-destination".into(), + self.config.destination.clone(), + ); + + let (status, response_body) = self + .transport + .post_json(&self.config.endpoint, &body, headers) + .await?; + if !(200..300).contains(&status) { + return Err(CommandDispatchError::Transport(format!( + "remote writer returned status {status}" + ))); + } + let response: CommandResponse = serde_json::from_slice(&response_body).map_err(|error| { + CommandDispatchError::Transport(format!( + "remote writer returned invalid response: {error}" + )) + })?; + Ok(response) + } + + fn kind(&self) -> &'static str { + "remote" + } +} + +/// In-memory loopback transport for parity tests. +#[allow(dead_code)] +pub struct LoopbackRemoteTransport { + handler: Arc CommandResponse + Send + Sync>, +} + +#[allow(dead_code)] +impl LoopbackRemoteTransport { + pub fn new( + handler: impl Fn(CommandRequest) -> CommandResponse + Send + Sync + 'static, + ) -> Self { + Self { + handler: Arc::new(handler), + } + } +} + +#[async_trait] +impl RemoteCommandTransport for LoopbackRemoteTransport { + async fn post_json( + &self, + _endpoint: &str, + body: &[u8], + _headers: BTreeMap, + ) -> Result<(u16, Vec), CommandDispatchError> { + let envelope: CommandDispatchEnvelope = serde_json::from_slice(body).map_err(|error| { + CommandDispatchError::Rejected(format!("invalid remote envelope: {error}")) + })?; + let request = envelope + .into_request() + .map_err(CommandDispatchError::Rejected)?; + let response = (self.handler)(request); + let bytes = serde_json::to_vec(&response).map_err(|error| { + CommandDispatchError::Transport(format!("encode loopback response: {error}")) + })?; + Ok((200, bytes)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[tokio::test] + async fn remote_loopback_preserves_command_bytes_and_strips_forwarded_roles() { + let transport = Arc::new(LoopbackRemoteTransport::new(|request| { + assert!(!request.session_variables.contains_key("x-roles")); + CommandResponse { + status: 200, + body: json!({ "ok": true, "command": request.command }), + } + })); + let dispatcher = RemoteCommandDispatcher::new( + RemoteDispatchConfig { + destination: "todo-writer".into(), + endpoint: "https://writer.example/commands".into(), + trust: RemoteTrustMode::MutualTlsServiceIdentity, + max_body_bytes: 64 * 1024, + timeout_ms: 5_000, + }, + transport, + ) + .unwrap(); + assert_eq!(dispatcher.profile(), APPROVED_REMOTE_DISPATCH_PROFILE); + + let mut session = HashMap::new(); + session.insert("x-user-id".into(), "user-1".into()); + session.insert("x-roles".into(), "admin".into()); + let response = dispatcher + .dispatch(&CommandRequest { + command: "todo.create".into(), + input: json!({ "title": "hi" }), + session_variables: session, + }) + .await + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body["command"], "todo.create"); + } + + #[test] + fn remote_config_requires_https_and_approved_trust() { + let err = RemoteDispatchConfig { + destination: "w".into(), + endpoint: "http://insecure".into(), + trust: RemoteTrustMode::MutualTlsServiceIdentity, + max_body_bytes: 1, + timeout_ms: 1, + } + .validate() + .unwrap_err(); + assert!(err.to_string().contains("https")); + } +} diff --git a/src/graphql/client_manifest/build.rs b/src/graphql/client_manifest/build.rs index 67543004..8e9de235 100644 --- a/src/graphql/client_manifest/build.rs +++ b/src/graphql/client_manifest/build.rs @@ -38,10 +38,17 @@ pub(super) fn client_manifest_from_surface_with_execution( ( SurfaceSelection::Application { name: selected_name, - roles: selected_roles, + eligible_roles: selected_eligible_roles, + schema_roles: selected_schema_roles, }, - ClientSurfaceIdentity::Application { name, roles }, - ) if selected_name == name && selected_roles == roles => {} + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + }, + ) if selected_name == name + && selected_eligible_roles == eligible_roles + && selected_schema_roles == schema_roles => {} _ => { return Err(ClientManifestError( "client Surface identity does not match its authorization selection provenance" diff --git a/src/graphql/client_manifest/export.rs b/src/graphql/client_manifest/export.rs index 93638982..8c2e3079 100644 --- a/src/graphql/client_manifest/export.rs +++ b/src/graphql/client_manifest/export.rs @@ -37,7 +37,7 @@ impl DistributedClientSurfaceExport { /// Safe, low-boilerplate export path: authorization identity is derived /// from the selected Surface and cannot be caller-asserted. - pub(crate) fn from_selected( + pub fn from_selected( service_id: impl Into, surface: impl Into>, ) -> Result { @@ -59,49 +59,64 @@ impl DistributedClientSurfaceExport { )); } SurfaceSelection::Role { name } => ClientSurfaceIdentity::role(name), - SurfaceSelection::Application { name, roles } => { - ClientSurfaceIdentity::application(name, roles.clone()) + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles.clone(), + schema_roles.clone(), + ) } }; validate_service_provenance(&service_id, &surface)?; Ok(Self::new(service_id, identity, surface, execution)) } - /// Build a portable export whose service identity comes from the same - /// project manifest that supplied its table inventory. - pub fn from_project( - project: &DistributedProjectManifest, + /// Build a selected client contract without executable service provenance. + /// + /// This is the contract-only compiler boundary. It accepts the same + /// already-selected Surface IR as the runtime export but never constructs + /// a repository, lock manager, Service, or handler mount. + pub fn from_contract( + service_id: impl Into, surface: impl Into>, ) -> Result { let surface = surface.into(); - for model in surface.models.values() { - let Some(original) = project.tables.iter().find(|schema| { - schema.model_name == model.model_name && schema.table_name == model.table_name - }) else { - return Err(ClientManifestError(format!( - "selected Surface model `{}` does not match the supplied project manifest inventory", - model.model_name - ))); - }; - let mut selected_schema = model.schema.clone(); - for column in &mut selected_schema.columns { - if let Some(original_column) = original - .columns - .iter() - .find(|candidate| candidate.column_name == column.column_name) - { - column.skipped = original_column.skipped; - } + let service_id = service_id.into(); + let identity = match &surface.selection { + SurfaceSelection::Catalog => { + return Err(ClientManifestError( + "client exports require an explicitly role- or application-selected Surface" + .into(), + )); } - selected_schema.relationships = original.relationships.clone(); - if &selected_schema != original { - return Err(ClientManifestError(format!( - "selected Surface model `{}` does not match the supplied project manifest inventory", - model.model_name - ))); + SurfaceSelection::Role { name } => ClientSurfaceIdentity::role(name), + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles.clone(), + schema_roles.clone(), + ) } + }; + if surface.service_binding.is_some() { + return Err(ClientManifestError( + "contract-only client export cannot carry executable Service provenance".into(), + )); } - Self::from_selected(project.name.clone(), surface) + Ok(Self::new( + service_id, + identity, + surface, + ClientExecutionLimits::default(), + )) } pub fn manifest(&self) -> Result { diff --git a/src/graphql/client_manifest/identity.rs b/src/graphql/client_manifest/identity.rs index 59b899c4..eb059670 100644 --- a/src/graphql/client_manifest/identity.rs +++ b/src/graphql/client_manifest/identity.rs @@ -4,7 +4,14 @@ use super::*; #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ClientSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + /// `eligible_roles` is the canonical wire identity for principals who may + /// open the application surface. `schema_roles` is the distinct role set + /// used to derive the shared schema/command contract. + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl ClientSurfaceIdentity { @@ -14,14 +21,27 @@ impl ClientSurfaceIdentity { pub fn application( name: impl Into, - roles: impl IntoIterator>, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, ) -> Self { - let mut roles: Vec = roles.into_iter().map(Into::into).collect(); - roles.sort(); - roles.dedup(); + Self::application_with_schema_roles(name, eligible_roles, schema_roles) + } + + pub fn application_with_schema_roles( + name: impl Into, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, + ) -> Self { + let mut eligible_roles: Vec = eligible_roles.into_iter().map(Into::into).collect(); + let mut schema_roles: Vec = schema_roles.into_iter().map(Into::into).collect(); + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); Self::Application { name: name.into(), - roles, + eligible_roles, + schema_roles, } } @@ -34,20 +54,48 @@ impl ClientSurfaceIdentity { Self::Application { name, .. } if name.trim().is_empty() => Err(ClientManifestError( "application surface name must not be empty".into(), )), - Self::Application { name, mut roles } => { - if roles.iter().any(|role| role.trim().is_empty()) { + Self::Application { + name, + mut eligible_roles, + mut schema_roles, + } => { + if eligible_roles.iter().any(|role| role.trim().is_empty()) { return Err(ClientManifestError(format!( "application surface `{name}` contains an empty role id" ))); } - roles.sort(); - roles.dedup(); - if roles.is_empty() { + if schema_roles.iter().any(|role| role.trim().is_empty()) { + return Err(ClientManifestError(format!( + "application surface `{name}` contains an empty schema role id" + ))); + } + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + if eligible_roles.is_empty() { + return Err(ClientManifestError(format!( + "application surface `{name}` must declare at least one eligible role" + ))); + } + if schema_roles.is_empty() { + return Err(ClientManifestError(format!( + "application surface `{name}` must declare at least one schema role" + ))); + } + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { return Err(ClientManifestError(format!( - "application surface `{name}` must declare at least one role" + "application surface `{name}` schema roles must be a subset of eligible roles" ))); } - Ok(Self::Application { name, roles }) + Ok(Self::Application { + name, + eligible_roles, + schema_roles, + }) } } } diff --git a/src/graphql/client_manifest/mod.rs b/src/graphql/client_manifest/mod.rs index 11546aec..c8fb8eed 100644 --- a/src/graphql/client_manifest/mod.rs +++ b/src/graphql/client_manifest/mod.rs @@ -36,7 +36,6 @@ use super::surface::{ SurfaceCommand, SurfaceCommandShape, SurfaceRelationshipKeys, SurfaceRowPolicy, SurfaceSelection, SurfaceTypeDef, }; -use crate::manifest::DistributedProjectManifest; use crate::table::RelationshipKind; use build::client_manifest_from_surface_with_execution; @@ -75,8 +74,9 @@ pub use types::{ ClientRootKind, ClientRootOperation, ClientRowPolicy, ClientTrustedPresetDescriptor, ClientTypeDef, ClientTypeField, CommandConfirmationsExtension, CommandConsistencyExtension, CommandDirectProjectionExtension, CommandEffectsExtension, CommandInputDefaultsExtension, - CommandProjectionArmRef, CommandProjectionExtension, CommandProjectionPreviewOccurrence, - CommandProjectionPreviewValue, DistributedClientManifest, ModelNormalization, + ClientCommandPureArg, ClientCommandPureReduce, CommandProjectionArmRef, + CommandProjectionExtension, CommandProjectionPreviewOccurrence, CommandProjectionPreviewValue, + DistributedClientManifest, ModelNormalization, RelationshipKeyMapping, ScalarCodec, }; pub(crate) use validation::trusted_preset_descriptors; diff --git a/src/graphql/client_manifest/projections.rs b/src/graphql/client_manifest/projections.rs index a37d66d9..80fb46d9 100644 --- a/src/graphql/client_manifest/projections.rs +++ b/src/graphql/client_manifest/projections.rs @@ -156,79 +156,399 @@ pub(super) fn command_projection_extension( .collect(); let mut preview_occurrences = Vec::new(); - for preview in &command.projections.previews { - let preview_event = event_ref(&preview.selector); - if program_arms.iter().all(|arm| arm.event != preview_event) { - continue; + if command.projections.previews.is_empty() { + // Automatic optimism: input + defaults + row-policy claims + emits + + // projection arms. No third mapping document is required. Unresolved + // slots stay Unknown and fall back to revalidation. + let claim_presets = surface_row_policy_claim_presets(surface); + let mut seen_event_ids = BTreeSet::new(); + for selector in emitted { + let preview_event = event_ref(selector); + if !seen_event_ids.insert(preview_event.id.clone()) { + continue; + } + if program_arms.iter().all(|arm| arm.event != preview_event) { + continue; + } + let mut values = slot_origins + .iter() + .filter(|origin| origin.event == *selector) + .map(|origin| CommandProjectionPreviewValue { + slot: origin.slot.clone(), + source: auto_preview_source(origin, command, &claim_presets), + }) + .collect::>(); + values.sort_by(|left, right| left.slot.cmp(&right.slot)); + values.dedup_by(|left, right| left.slot == right.slot); + let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { + ClientManifestError(format!( + "command `{}` declares too many projection preview occurrences", + command.command_name + )) + })?; + preview_occurrences.push(CommandProjectionPreviewOccurrence { + ordinal, + event: preview_event, + values, + }); } - let occurrence_origins = slot_origins - .iter() - .filter(|origin| origin.event == preview.selector) - .collect::>(); - let mut values = occurrence_origins - .into_iter() - .map(|origin| { - let source = preview - .preview - .fields + } else { + for preview in &command.projections.previews { + let preview_event = event_ref(&preview.selector); + if program_arms.iter().all(|arm| arm.event != preview_event) { + continue; + } + let occurrence_origins = slot_origins + .iter() + .filter(|origin| origin.event == preview.selector) + .collect::>(); + let mut values = occurrence_origins + .into_iter() + .map(|origin| { + let source = preview + .preview + .fields + .iter() + .find(|field| { + let target = match field.envelope { + Some(envelope) => SlotTarget::Envelope { field: envelope }, + None => SlotTarget::BodyPath { + path: field.body_path.clone(), + }, + }; + target == origin.target + }) + .map(|field| { + client_preview_source( + &field.source, + matches!(origin.target, SlotTarget::BodyPath { .. }), + match origin.target { + SlotTarget::Envelope { field } => Some(field), + SlotTarget::BodyPath { .. } => None, + }, + field.body_type, + field.body_rust_type, + field.body_nullable, + field.body_always_present, + &origin.value_type, + command, + ) + }) + .transpose()? + .unwrap_or(ClientProjectionPreviewSource::Unknown); + Ok(CommandProjectionPreviewValue { + slot: origin.slot.clone(), + source, + }) + }) + .collect::, ClientManifestError>>()?; + values.sort_by(|left, right| left.slot.cmp(&right.slot)); + values.dedup_by(|left, right| left.slot == right.slot); + let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { + ClientManifestError(format!( + "command `{}` declares too many projection preview occurrences", + command.command_name + )) + })?; + preview_occurrences.push(CommandProjectionPreviewOccurrence { + ordinal, + event: preview_event, + values, + }); + } + } + + let pure_reduces = command + .projections + .pure_reduces + .iter() + .map(|reduce| { + use crate::graphql::command_contract::CommandProjectionPreviewSource as ServerSource; + use crate::graphql::client_manifest::ClientProjectionPreviewSource as ClientSource; + let map_source = |source: &ServerSource| -> Result { + Ok(match source { + ServerSource::InputPath { path } => ClientSource::Input { + path: path.clone(), + }, + ServerSource::GeneratedDefaultPath { path } => ClientSource::GeneratedDefault { + path: path.clone(), + }, + ServerSource::TrustedPreset { name, codec } => ClientSource::TrustedPreset { + name: name.clone(), + codec: codec.clone(), + }, + other => { + return Err(ClientManifestError(format!( + "command `{}` pure reduce uses unsupported source {other:?}", + command.command_name + ))); + } + }) + }; + Ok(crate::graphql::client_manifest::ClientCommandPureReduce { + fn_name: reduce.fn_name.clone(), + client_module: reduce.client_module.clone(), + client_export: reduce.client_export.clone(), + model: reduce.model.clone(), + key: reduce + .key .iter() - .find(|field| { - let target = match field.envelope { - Some(envelope) => SlotTarget::Envelope { field: envelope }, - None => SlotTarget::BodyPath { - path: field.body_path.clone(), - }, - }; - target == origin.target + .map(|arg| { + Ok(crate::graphql::client_manifest::ClientCommandPureArg { + name: arg.name.clone(), + source: map_source(&arg.source)?, + }) }) - .map(|field| { - client_preview_source( - &field.source, - matches!(origin.target, SlotTarget::BodyPath { .. }), - match origin.target { - SlotTarget::Envelope { field } => Some(field), - SlotTarget::BodyPath { .. } => None, - }, - field.body_type, - field.body_rust_type, - field.body_nullable, - field.body_always_present, - &origin.value_type, - command, - ) + .collect::, ClientManifestError>>()?, + args: reduce + .args + .iter() + .map(|arg| { + Ok(crate::graphql::client_manifest::ClientCommandPureArg { + name: arg.name.clone(), + source: map_source(&arg.source)?, + }) }) - .transpose()? - .unwrap_or(ClientProjectionPreviewSource::Unknown); - Ok(CommandProjectionPreviewValue { - slot: origin.slot.clone(), - source, - }) + .collect::, ClientManifestError>>()?, + assign: reduce.assign.clone(), }) - .collect::, ClientManifestError>>()?; - values.sort_by(|left, right| left.slot.cmp(&right.slot)); - values.dedup_by(|left, right| left.slot == right.slot); - let ordinal = u32::try_from(preview_occurrences.len()).map_err(|_| { - ClientManifestError(format!( - "command `{}` declares too many projection preview occurrences", - command.command_name - )) - })?; - preview_occurrences.push(CommandProjectionPreviewOccurrence { - ordinal, - event: preview_event, - values, - }); - } + }) + .collect::, ClientManifestError>>()?; Ok(Some(CommandProjectionExtension { version: COMMAND_PROJECTION_EXTENSION_VERSION, event_set, program_arms, preview_occurrences, + pure_reduces, fallback: ClientProjectionFallback::Revalidate, })) } +/// Map row-policy `column == claim(header)` bindings to trusted-preset sources. +/// +/// Keyed by the model field / body-path leaf name so automatic optimism can +/// fill owner-like slots without a third mapping API. +fn surface_row_policy_claim_presets( + surface: &Surface, +) -> BTreeMap { + let mut presets = BTreeMap::new(); + for model in surface.models.values() { + let SurfaceRowPolicy::Predicate(expression) = &model.row_policy else { + continue; + }; + collect_row_policy_claim_presets(expression, model, surface, &mut presets); + } + presets +} + +fn collect_row_policy_claim_presets( + expression: &FilterExpr, + model: &crate::graphql::surface::SurfaceModel, + surface: &Surface, + presets: &mut BTreeMap, +) { + match expression { + FilterExpr::And(expressions) | FilterExpr::Or(expressions) => { + for expression in expressions { + collect_row_policy_claim_presets(expression, model, surface, presets); + } + } + FilterExpr::Not(expression) => { + collect_row_policy_claim_presets(expression, model, surface, presets); + } + FilterExpr::Cmp { + column, + rhs: Operand::Claim(claim), + .. + } => { + insert_row_policy_claim_preset(model, column, &claim.header, presets); + } + FilterExpr::In { column, values, .. } => { + for value in values { + if let Operand::Claim(claim) = value { + insert_row_policy_claim_preset(model, column, &claim.header, presets); + } + } + } + FilterExpr::Rel { field, predicate } => { + let Some(relationship) = model + .relationships + .iter() + .find(|relationship| relationship.name == *field) + else { + return; + }; + let Some(target) = surface.models.get(&relationship.target_model) else { + return; + }; + collect_row_policy_claim_presets(predicate, target, surface, presets); + } + FilterExpr::Cmp { .. } | FilterExpr::IsNull { .. } => {} + } +} + +fn insert_row_policy_claim_preset( + model: &crate::graphql::surface::SurfaceModel, + column: &str, + claim: &str, + presets: &mut BTreeMap, +) { + let codec = resolve_row_policy_column_codec(model, column); + let Some(codec) = codec else { + return; + }; + if matches!(codec, "base64" | "json") { + return; + } + // Index by the policy column text and every alias (logical field name, + // physical column name) so body-path leaves and GraphQL field names both + // resolve when they describe the same column. + let mut keys = BTreeSet::from([column.to_owned()]); + if let Some(schema_column) = model.schema.columns.iter().find(|candidate| { + !candidate.skipped + && (candidate.field_name == column || candidate.column_name == column) + }) { + keys.insert(schema_column.field_name.clone()); + keys.insert(schema_column.column_name.clone()); + } + for field in &model.columns { + if field.name == column || keys.contains(&field.name) { + keys.insert(field.name.clone()); + } + } + for key in keys { + match presets.entry(key) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert((claim.to_owned(), codec.to_owned())); + } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().0 == claim && entry.get().1 == codec => {} + // Conflicting claim/codec for the same column is not portable; leave + // the first binding and let remaining slots fall through to Unknown. + std::collections::btree_map::Entry::Occupied(_) => {} + } + } +} + +fn resolve_row_policy_column_codec( + model: &crate::graphql::surface::SurfaceModel, + column: &str, +) -> Option<&'static str> { + if let Some(field) = model.columns.iter().find(|field| field.name == column) { + return super::scalar_codec(&field.scalar); + } + let schema_column = model.schema.columns.iter().find(|candidate| { + !candidate.skipped + && (candidate.field_name == column || candidate.column_name == column) + })?; + if let Some(field) = model + .columns + .iter() + .find(|field| field.name == schema_column.column_name) + { + return super::scalar_codec(&field.scalar); + } + match schema_column.column_type { + crate::table::ColumnType::Text | crate::table::ColumnType::Timestamp => Some("string"), + crate::table::ColumnType::Boolean => Some("boolean"), + crate::table::ColumnType::Integer => Some("int32"), + crate::table::ColumnType::UnsignedInteger => Some("json_number_precision_limited"), + crate::table::ColumnType::Float => Some("float64"), + crate::table::ColumnType::Json => Some("json"), + crate::table::ColumnType::Bytes => Some("base64"), + crate::table::ColumnType::Unsupported(_) => None, + } +} + +/// Derive one client preview source from command input / defaults / claims. +fn auto_preview_source( + origin: &SlotOrigin, + command: &SurfaceCommand, + claim_presets: &BTreeMap, +) -> ClientProjectionPreviewSource { + match &origin.target { + SlotTarget::Envelope { + field: ProjectionEnvelopeField::AggregateId, + } => { + if let Some(path) = auto_aggregate_id_path(command, &origin.value_type) { + if command.input_defaults.iter().any(|default| default.path == path) { + ClientProjectionPreviewSource::GeneratedDefault { path } + } else { + ClientProjectionPreviewSource::Input { path } + } + } else { + ClientProjectionPreviewSource::Unknown + } + } + SlotTarget::Envelope { .. } => ClientProjectionPreviewSource::Unknown, + SlotTarget::BodyPath { path } => { + if command_input_field(&command.input, path).is_some_and(|field| { + input_field_compatible(field, &origin.value_type, None, None) + }) { + if command + .input_defaults + .iter() + .any(|default| default.path == *path) + { + return ClientProjectionPreviewSource::GeneratedDefault { + path: path.clone(), + }; + } + return ClientProjectionPreviewSource::Input { + path: path.clone(), + }; + } + if let Some(leaf) = path.last() { + if let Some((name, codec)) = claim_presets.get(leaf) { + if codec_compatible(codec, &origin.value_type) { + return ClientProjectionPreviewSource::TrustedPreset { + name: name.clone(), + codec: codec.clone(), + }; + } + } + } + ClientProjectionPreviewSource::Unknown + } + } +} + +/// Pick the best aggregate-id input path: generated id-like fields first, then +/// explicit id-like fields (`id`, `*_id`, or GraphQL `ID`). +fn auto_aggregate_id_path( + command: &SurfaceCommand, + expected: &ProjectionValueType, +) -> Option> { + let SurfaceCommandShape::Typed(definition) = &command.input else { + return None; + }; + let mut generated = Vec::new(); + let mut explicit = Vec::new(); + for field in &definition.fields { + if field.list || !input_field_compatible(field, expected, None, None) { + continue; + } + let id_like = field.type_name == "ID" + || field.name == "id" + || field.name.ends_with("_id"); + if !id_like { + continue; + } + let path = vec![field.name.clone()]; + if command + .input_defaults + .iter() + .any(|default| default.path == path) + { + generated.push(path); + } else { + explicit.push(path); + } + } + generated.into_iter().chain(explicit).next() +} + fn lower_program( program_id: crate::ProjectionProgramId, program: &SurfaceSelectedProjectionProgram, @@ -1224,7 +1544,7 @@ mod tests { } #[test] - fn allowed_event_arms_do_not_invent_optimistic_occurrences() { + fn allowed_event_arms_auto_derive_optimistic_occurrences_when_previews_absent() { let selector_a = typed_selector::(); let selector_b = typed_selector::(); let surface = surface_with_modeled([modeled( @@ -1245,10 +1565,179 @@ mod tests { .expect("eligible allowed arms remain visible"); assert_eq!(projection.event_set.len(), 2); assert_eq!(projection.program_arms.len(), 2); - assert!( - projection.preview_occurrences.is_empty(), - "an allowed actual event is not an optimistic prediction" + assert_eq!( + projection.preview_occurrences.len(), + 2, + "emits + projection arms auto-derive one occurrence per event when .applies is absent" ); + // AggregateId envelope has no id-like input field named value → Unknown. + assert!(projection + .preview_occurrences + .iter() + .all(|occurrence| occurrence.values.iter().all(|value| { + matches!(value.source, ClientProjectionPreviewSource::Unknown) + }))); + } + + #[test] + fn auto_optimism_maps_input_defaults_and_row_policy_claims() { + use crate::graphql::command_contract::{CommandInputDefault, InputDefaultGenerator}; + use crate::graphql::{claim, col}; + + let selector = typed_selector::(); + let selected = SurfaceSelectedProjectionProgram { + name: "auto-optimism".into(), + version: 1, + ir_version: crate::projection::PROJECTION_PROGRAM_IR_VERSION, + operation_semantics_version: crate::projection::PROJECTION_OPERATION_SEMANTICS_VERSION, + partition: ProjectionPartition::Unit, + arms: vec![SurfaceProjectionArm { + arm_id: "auto-arm".into(), + selector, + operations: vec![SurfaceProjectionOperation { + operation_id: "auto-upsert".into(), + staging_ordinal: 0, + kind: ProjectionMutationKind::Upsert, + model: "TodoView".into(), + storage: "todos".into(), + key: vec![ProjectionKeyField::try_new( + 0, + "todo_id", + ProjectionExpression::body_path(ProjectionValueType::String, ["todo_id"]) + .unwrap(), + ) + .unwrap()], + fields: vec![ + crate::projection::ProjectionField::try_new( + 0, + "owner_id", + crate::projection::ProjectionAssignment::Set( + ProjectionExpression::body_path( + ProjectionValueType::String, + ["owner_id"], + ) + .unwrap(), + ), + ) + .unwrap(), + crate::projection::ProjectionField::try_new( + 1, + "title", + crate::projection::ProjectionAssignment::Set( + ProjectionExpression::body_path( + ProjectionValueType::String, + ["title"], + ) + .unwrap(), + ), + ) + .unwrap(), + ], + relationship_effects: Vec::new(), + invalidations: Vec::new(), + force_revalidate: false, + }], + }], + }; + let mut surface = + surface_with_modeled([modeled(30, ProjectionPlacement::Eventual, Some(selected))]); + surface.models.get_mut("TodoView").unwrap().row_policy = + crate::graphql::surface::SurfaceRowPolicy::Predicate( + col("owner_id").eq(claim("x-user-id")), + ); + + let mut command = SurfaceCommand { + command_name: "todo.create".into(), + field_name: "todos_create".into(), + roles: vec!["user".into()], + input: SurfaceCommandShape::Typed(SurfaceTypeDef { + name: "TodoCreateInput".into(), + fields: vec![ + SurfaceTypeField { + name: "todo_id".into(), + type_name: "ID".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + SurfaceTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }, + ], + }), + output: SurfaceCommandShape::None, + consistency: CommandConsistency::Succeeded, + input_defaults: vec![CommandInputDefault { + path: vec!["todo_id".into()], + generator: InputDefaultGenerator::UuidV7, + }], + effects: None, + confirmations: Vec::new(), + projected_model: None, + direct_projection: None, + projections: Default::default(), + confirmation_unavailable: false, + }; + command.projections.add_event_set(crate::events![PreviewA]); + + let projection = command_projection_extension(&command, &surface, &[]) + .unwrap() + .expect("auto optimism extension"); + assert_eq!(projection.preview_occurrences.len(), 1); + let sources: BTreeMap<_, _> = projection.preview_occurrences[0] + .values + .iter() + .map(|value| (value.slot.clone(), value.source.clone())) + .collect(); + // Resolve slots via lowered program so we match opaque ids. + let (programs, _) = projection_manifest(&surface).unwrap(); + let arm = &programs[0].arms[0]; + let todo_slot = match &arm.operations[0].key[0].expression { + ClientProjectionExpression::Slot { slot, .. } => slot.clone(), + other => panic!("expected key slot, got {other:?}"), + }; + let owner_slot = match &arm.operations[0].fields.iter().find(|f| f.name == "owner_fk") { + Some(field) => match &field.assignment { + ClientProjectionAssignment::Set { + expression: ClientProjectionExpression::Slot { slot, .. }, + } => slot.clone(), + other => panic!("expected owner slot set, got {other:?}"), + }, + None => panic!("owner field missing"), + }; + let title_slot = match &arm.operations[0] + .fields + .iter() + .find(|f| f.name == "todo_title") + { + Some(field) => match &field.assignment { + ClientProjectionAssignment::Set { + expression: ClientProjectionExpression::Slot { slot, .. }, + } => slot.clone(), + other => panic!("expected title slot set, got {other:?}"), + }, + None => panic!("title field missing"), + }; + assert!(matches!( + sources.get(&todo_slot), + Some(ClientProjectionPreviewSource::GeneratedDefault { path }) + if path == &["todo_id"] + )); + assert!(matches!( + sources.get(&owner_slot), + Some(ClientProjectionPreviewSource::TrustedPreset { name, codec }) + if name == "x-user-id" && codec == "string" + )); + assert!(matches!( + sources.get(&title_slot), + Some(ClientProjectionPreviewSource::Input { path }) if path == &["title"] + )); } #[test] diff --git a/src/graphql/client_manifest/tests.rs b/src/graphql/client_manifest/tests.rs index c5ddc0f3..6fbaf385 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -908,12 +908,12 @@ fn role_and_application_partition_manifests_hide_raw_paths_and_denied_values() { ); let all_grants = grants(); let role = surface_for_role(&full, "user", &all_grants["user"]).unwrap(); - let application = surface_for_application(&full, "web", &["user".into()], &all_grants).unwrap(); + let application = surface_for_application(&full, "web", &["user".into()], &["user".into()], &all_grants).unwrap(); for (identity, selected) in [ (ClientSurfaceIdentity::role("user"), role), ( - ClientSurfaceIdentity::application("web", ["user"]), + ClientSurfaceIdentity::application("web", ["user"], ["user"]), application, ), ] { @@ -1833,11 +1833,17 @@ fn application_surface_is_common_contract_with_safe_role_limit_semantics() { let full = full_surface(); let all_grants = grants(); let selected = - surface_for_application(&full, "web", &["user".into(), "admin".into()], &all_grants) + surface_for_application( + &full, + "web", + &["user".into(), "admin".into()], + &["user".into(), "admin".into()], + &all_grants, + ) .unwrap(); let manifest = client_manifest_from_surface( "todos-service", - ClientSurfaceIdentity::application("web", ["admin", "user"]), + ClientSurfaceIdentity::application("web", ["admin", "user"], ["admin", "user"]), &selected, ) .unwrap(); @@ -1938,6 +1944,7 @@ fn mixed_target_projectors_are_omitted_for_role_and_application_surfaces() { &full, "web", &["admin".into(), "restricted".into()], + &["admin".into(), "restricted".into()], &BTreeMap::from([("admin".into(), admin), ("restricted".into(), restricted)]), ) .unwrap(); @@ -2309,12 +2316,20 @@ fn relational_row_policy_is_server_only_when_relationship_key_is_hidden() { fn application_role_sets_are_canonical_before_fingerprinting() { let full = full_surface(); let selected = - surface_for_application(&full, "web", &["admin".into(), "user".into()], &grants()).unwrap(); + surface_for_application( + &full, + "web", + &["admin".into(), "user".into()], + &["admin".into(), "user".into()], + &grants(), + ) + .unwrap(); let first = client_manifest_from_surface( "todos-service", ClientSurfaceIdentity::Application { name: "web".into(), - roles: vec!["user".into(), "admin".into(), "user".into()], + eligible_roles: vec!["user".into(), "admin".into(), "user".into()], + schema_roles: vec!["user".into(), "admin".into(), "user".into()], }, &selected, ) @@ -2323,7 +2338,8 @@ fn application_role_sets_are_canonical_before_fingerprinting() { "todos-service", ClientSurfaceIdentity::Application { name: "web".into(), - roles: vec!["admin".into(), "user".into()], + eligible_roles: vec!["admin".into(), "user".into()], + schema_roles: vec!["admin".into(), "user".into()], }, &selected, ) @@ -2332,7 +2348,7 @@ fn application_role_sets_are_canonical_before_fingerprinting() { assert_eq!(first.schema_fingerprint, second.schema_fingerprint); assert_eq!( first.surface, - ClientSurfaceIdentity::application("web", ["admin", "user"]) + ClientSurfaceIdentity::application("web", ["admin", "user"], ["admin", "user"]) ); } @@ -2346,9 +2362,9 @@ fn catalog_or_mismatched_surface_cannot_be_labeled_as_authorized() { .contains("explicitly role- or application-selected")); let selected = surface_for_role(&full, "user", &grants()["user"]).unwrap(); - let wrong_project = DistributedProjectManifest::new("wrong-service").table_schema(users()); let inventory_error = - DistributedClientSurfaceExport::from_project(&wrong_project, selected.clone()).unwrap_err(); + DistributedClientSurfaceExport::from_selected("wrong-service", selected.clone()) + .unwrap_err(); assert!(inventory_error.to_string().contains("does not match")); let error = client_manifest_from_surface( diff --git a/src/graphql/client_manifest/types.rs b/src/graphql/client_manifest/types.rs index 348f0045..a0741e94 100644 --- a/src/graphql/client_manifest/types.rs +++ b/src/graphql/client_manifest/types.rs @@ -843,9 +843,33 @@ pub struct CommandProjectionExtension { /// The client applies these in ordinal order as one overlay. The actual /// ordered command delta reconciles and replaces that overlay. pub preview_occurrences: Vec, + /// Pure reducers over known cache rows (client auto-optimism). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pure_reduces: Vec, pub fallback: ClientProjectionFallback, } +/// Pure reduce declaration on the client manifest (server-exported). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientCommandPureReduce { + pub fn_name: String, + pub client_module: String, + pub client_export: String, + pub model: String, + pub key: Vec, + pub args: Vec, + pub assign: Vec, +} + +/// Pure reduce key/arg with preview-style source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientCommandPureArg { + pub name: String, + pub source: ClientProjectionPreviewSource, +} + impl<'de> Deserialize<'de> for CommandProjectionExtension { fn deserialize(deserializer: D) -> Result where @@ -859,6 +883,8 @@ impl<'de> Deserialize<'de> for CommandProjectionExtension { event_set: Vec, program_arms: Vec, preview_occurrences: Vec, + #[serde(default)] + pure_reduces: Vec, fallback: ClientProjectionFallback, } @@ -919,6 +945,7 @@ impl<'de> Deserialize<'de> for CommandProjectionExtension { event_set: wire.event_set, program_arms: wire.program_arms, preview_occurrences: wire.preview_occurrences, + pure_reduces: wire.pure_reduces, fallback: wire.fallback, }) } diff --git a/src/graphql/command_contract/effect_wire.rs b/src/graphql/command_contract/effect_wire.rs index 4f244838..36107482 100644 --- a/src/graphql/command_contract/effect_wire.rs +++ b/src/graphql/command_contract/effect_wire.rs @@ -298,7 +298,7 @@ where pub struct CompiledInputDefault(CommandInputDefault, PhantomData); /// Declaration-owned generated defaults for one exact command input type. -pub struct CompiledInputDefaults(pub(super) Vec, PhantomData); +pub struct CompiledInputDefaults(pub(crate) Vec, PhantomData); #[doc(hidden)] pub fn __input_default_uuid_v7() -> CompiledInputDefault diff --git a/src/graphql/command_contract/mod.rs b/src/graphql/command_contract/mod.rs index 5e28c303..65354a21 100644 --- a/src/graphql/command_contract/mod.rs +++ b/src/graphql/command_contract/mod.rs @@ -50,12 +50,15 @@ pub use outcomes::{ pub(crate) use projection_obligations::{ validate_projection_confirmation_count, CommandInputDefault, CommandProjectionConfirmation, }; +#[cfg(test)] +pub(crate) use projection_obligations::InputDefaultGenerator; pub(crate) use projections::CommandProjectionEvents; pub use projections::{ __command_projection_event_descriptor, __command_projection_event_preview, __command_projection_events, __command_projection_preview_constant, - __command_projection_state_preview, CommandProjectionEventSet, CommandProjectionPreview, - CommandProjectionPreviewSource, + __command_projection_state_preview, CommandEventSet, CommandProjectionEventSet, + CommandProjectionPreview, CommandProjectionPreviewSource, CommandProjectionPureArg, + CommandProjectionPureReduce, }; // Re-exported for unit tests that resolve obligations through this module path. #[cfg_attr(not(test), allow(unused_imports))] @@ -65,5 +68,5 @@ pub(crate) use projection_obligations::{ pub(crate) use projection_proof::{ validate_resolved_direct_plan, CommandCommitProofError, ProjectionCommitProof, }; -pub use typed_command::{typed_command, TypedCommand}; +pub use typed_command::{command_transition, typed_command, TypedCommand}; pub(crate) use typed_command::{TypedCommandContract, TypedServiceCommandBinding}; diff --git a/src/graphql/command_contract/projections.rs b/src/graphql/command_contract/projections.rs index d6075021..76dee3af 100644 --- a/src/graphql/command_contract/projections.rs +++ b/src/graphql/command_contract/projections.rs @@ -154,11 +154,97 @@ pub(crate) struct CommandProjectionEventPreview { pub preview: CommandProjectionPreview, } +/// Pure reducer over a known cache row for client auto-optimism. +/// +/// The server/domain owns the pure semantics (e.g. `blob_domain::simulate_move`); +/// the client module/export is the shipped TypeScript twin invoked by the +/// replica when applying `projection.pureReduces`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CommandProjectionPureReduce { + /// Stable pure id, e.g. `blob.simulate_move`. + pub fn_name: String, + /// Path under app `$lib` without extension, e.g. `blob/simulate-move`. + pub client_module: String, + /// Named export in that module, e.g. `simulateMove`. + pub client_export: String, + /// Projection model id (e.g. `BlobGames`). + pub model: String, + /// Record key fields: `name` is the model field; `source` is input/default/preset. + pub key: Vec, + /// Pure function arguments (resolved like preview values). + pub args: Vec, + /// Fields taken from the pure result and patched onto the known row. + pub assign: Vec, +} + +/// One named pure-reduce binding (key field or pure arg). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CommandProjectionPureArg { + pub name: String, + pub source: CommandProjectionPreviewSource, +} + +impl CommandProjectionPureReduce { + pub fn new( + fn_name: impl Into, + client_module: impl Into, + client_export: impl Into, + model: impl Into, + ) -> Self { + Self { + fn_name: fn_name.into(), + client_module: client_module.into(), + client_export: client_export.into(), + model: model.into(), + key: Vec::new(), + args: Vec::new(), + assign: Vec::new(), + } + } + + #[must_use] + pub fn key_input( + mut self, + field: impl Into, + path: impl IntoIterator>, + ) -> Self { + self.key.push(CommandProjectionPureArg { + name: field.into(), + source: CommandProjectionPreviewSource::input(path), + }); + self + } + + #[must_use] + pub fn arg_input( + mut self, + name: impl Into, + path: impl IntoIterator>, + ) -> Self { + self.args.push(CommandProjectionPureArg { + name: name.into(), + source: CommandProjectionPreviewSource::input(path), + }); + self + } + + #[must_use] + pub fn assign(mut self, fields: impl IntoIterator>) -> Self { + self.assign + .extend(fields.into_iter().map(Into::into)); + self.assign.sort(); + self.assign.dedup(); + self + } +} + /// Exact outward events a command may emit, independent of any projector. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub(crate) struct CommandProjectionEvents { pub selectors: Vec, pub previews: Vec, + /// Pure reducers over known cache rows (client auto-optimism). + pub pure_reduces: Vec, pub declaration_errors: Vec, } @@ -192,6 +278,10 @@ impl CommandProjectionEvents { })); } + pub(crate) fn add_pure_reduce(&mut self, reduce: CommandProjectionPureReduce) { + self.pure_reduces.push(reduce); + } + pub(crate) fn canonicalize_and_validate(&mut self, command: &str) -> Result<(), String> { if let Some(error) = self.declaration_errors.first() { return Err(format!( @@ -267,6 +357,65 @@ impl CommandProjectionEvents { } } } + for reduce in &mut self.pure_reduces { + if reduce.fn_name.trim().is_empty() + || reduce.client_module.trim().is_empty() + || reduce.client_export.trim().is_empty() + || reduce.model.trim().is_empty() + { + return Err(format!( + "typed command `{command}` pure reduce requires non-empty fn, client_module, client_export, and model" + )); + } + if reduce.key.is_empty() { + return Err(format!( + "typed command `{command}` pure reduce `{}` requires at least one key field", + reduce.fn_name + )); + } + if reduce.assign.is_empty() { + return Err(format!( + "typed command `{command}` pure reduce `{}` requires at least one assign field", + reduce.fn_name + )); + } + reduce.key.sort_by(|a, b| a.name.cmp(&b.name)); + reduce.args.sort_by(|a, b| a.name.cmp(&b.name)); + reduce.assign.sort(); + reduce.assign.dedup(); + for arg in reduce.key.iter().chain(reduce.args.iter()) { + match &arg.source { + CommandProjectionPreviewSource::InputPath { path } + | CommandProjectionPreviewSource::GeneratedDefaultPath { path } => { + validate_path(command, "pure reduce", path)?; + } + CommandProjectionPreviewSource::TrustedPreset { name, codec } => { + if name.trim().is_empty() || codec.trim().is_empty() { + return Err(format!( + "typed command `{command}` pure reduce trusted preset name and codec must not be empty" + )); + } + } + other => { + return Err(format!( + "typed command `{command}` pure reduce `{}` arg `{}` uses unsupported source {other:?}", + reduce.fn_name, arg.name + )); + } + } + } + } + self.pure_reduces + .sort_by(|left, right| left.fn_name.cmp(&right.fn_name)); + if self + .pure_reduces + .windows(2) + .any(|pair| pair[0].fn_name == pair[1].fn_name) + { + return Err(format!( + "typed command `{command}` repeats pure reduce fn name" + )); + } Ok(()) } } @@ -408,6 +557,48 @@ pub fn __command_projection_preview_constant( } } +/// Type-level source of a command's outward domain-event set. +/// +/// Implemented for: +/// - every [`DomainEventContract`] marker (for example `TodoCreatedDomainEvent`) +/// - tuples of those markers +/// - `#[sourced]`-generated `domain_commands::*` transition witnesses (public +/// aggregate methods that call domain-marked `#[event]` recorders) +/// +/// Prefer [`crate::graphql::TypedCommand::emits_events`] with these types over +/// hand-maintaining a parallel event list when the domain already owns the +/// transition. +pub trait CommandEventSet { + /// Build the sealed event-set value used by command registration. + fn command_event_set() -> CommandProjectionEventSet; +} + +impl CommandEventSet for E { + fn command_event_set() -> CommandProjectionEventSet { + __command_projection_events([__command_projection_event_descriptor::()]) + } +} + +macro_rules! impl_command_event_set_tuple { + ($($E:ident),+) => { + impl<$($E: DomainEventContract),+> CommandEventSet for ($($E,)+) { + fn command_event_set() -> CommandProjectionEventSet { + __command_projection_events([ + $(__command_projection_event_descriptor::<$E>()),+ + ]) + } + } + }; +} + +impl_command_event_set_tuple!(E1, E2); +impl_command_event_set_tuple!(E1, E2, E3); +impl_command_event_set_tuple!(E1, E2, E3, E4); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7); +impl_command_event_set_tuple!(E1, E2, E3, E4, E5, E6, E7, E8); + /// Declare an exact, type-checked outward domain-event set. #[macro_export] macro_rules! events { diff --git a/src/graphql/command_contract/tests.rs b/src/graphql/command_contract/tests.rs index 055ba996..dbc4e1ee 100644 --- a/src/graphql/command_contract/tests.rs +++ b/src/graphql/command_contract/tests.rs @@ -203,6 +203,24 @@ fn command_events_are_exact_values_independent_of_projector_declarations() { ); } +#[test] +fn command_transition_fills_emits_from_domain_event_set() { + let from_transition = + super::command_transition::>("todo.complete") + .into_contract(); + let from_emits = typed_command::>("todo.complete") + .emits(crate::events![TodoCompleted]) + .into_contract(); + assert_eq!( + from_transition.projections.selectors, + from_emits.projections.selectors + ); + assert_eq!( + from_transition.projections.selectors[0].event_name(), + "todo.completed" + ); +} + #[test] fn command_event_registration_rejects_duplicates_and_conflicting_schemas() { let duplicate = typed_command::>("todo.duplicate") diff --git a/src/graphql/command_contract/typed_command.rs b/src/graphql/command_contract/typed_command.rs index 25dd0bfa..d4f3b82d 100644 --- a/src/graphql/command_contract/typed_command.rs +++ b/src/graphql/command_contract/typed_command.rs @@ -20,7 +20,9 @@ use super::projection_obligations::{ CommandInputDefault, CommandProjectionConfirmation, ProjectionObligationResolutionError, }; use super::projection_proof::{canonical_json, CommandCommitProofError}; -use super::projections::{CommandProjectionEvents, CommandProjectionPreview}; +use super::projections::{ + CommandProjectionEvents, CommandProjectionPreview, CommandProjectionPureReduce, +}; use crate::graphql::naming; use crate::graphql::types::{GraphqlInputType, GraphqlTypeDef}; use crate::microsvc::Session; @@ -600,6 +602,25 @@ where } } +/// Begin a typed command whose outward emit set is owned by a domain transition. +/// +/// `S` is typically a `#[sourced]` `domain_commands::*` witness (for example +/// `domain_commands::Create`) or a domain-event marker. The emit set is filled +/// automatically — callers do not also call [`.emits`](TypedCommand::emits) / +/// [`.emits_events`](TypedCommand::emits_events) unless they intentionally +/// extend the set. +/// +/// Prefer this over [`typed_command`] plus a hand-written event list when the +/// domain already defines the transition. +pub fn command_transition(name: &'static str) -> TypedCommand +where + S: super::CommandEventSet, + I: GraphqlInputType + DeserializeOwned + Send + 'static, + K: CommandOutcome, +{ + typed_command::(name).emits_events::() +} + impl TypedCommand { pub fn field_name(mut self, field_name: impl Into) -> Self { self.contract.field_name = field_name.into(); @@ -628,12 +649,29 @@ impl TypedCommand { /// /// This declaration is intentionally independent of projector ownership: /// one occurrence can fan out to zero, one, or many modeled programs. + /// + /// Prefer [`Self::emits_events`] when the set is a domain event marker or a + /// `#[sourced]` `domain_commands::*` transition witness. #[must_use] pub fn emits(mut self, events: super::CommandProjectionEventSet) -> Self { self.contract.projections.add_event_set(events); self } + /// Declare outward domain events from a type-level [`super::CommandEventSet`]. + /// + /// Equivalent to [`.emits`](Self::emits)`(events![...])` for the same + /// domain-event contracts, without a second hand-written event list when + /// the domain already owns the transition (event marker or generated + /// `domain_commands` witness). + #[must_use] + pub fn emits_events(mut self) -> Self { + self.contract + .projections + .add_event_set(S::command_event_set()); + self + } + /// Declare known mutation-input fields for client cache application. /// /// Maps command-known values (and unknowns) onto the emitted domain-event @@ -653,6 +691,17 @@ impl TypedCommand { self } + /// Declare a pure reducer over a known cache row for client auto-optimism. + /// + /// The pure function is domain-owned (e.g. `blob_domain::simulate_move`); + /// `client_module` / `client_export` name the TypeScript twin shipped with + /// the generated client and registered as `pureFunctions[fn_name]`. + #[must_use] + pub fn preview_reduce_known_record(mut self, reduce: CommandProjectionPureReduce) -> Self { + self.contract.projections.add_pure_reduce(reduce); + self + } + pub fn name(&self) -> &str { &self.contract.name } diff --git a/src/graphql/engine/builder.rs b/src/graphql/engine/builder.rs index bb137692..95cc8197 100644 --- a/src/graphql/engine/builder.rs +++ b/src/graphql/engine/builder.rs @@ -235,8 +235,8 @@ impl GraphqlEngineBuilder { } /// Set the stable service identity used by generated client manifests. /// - /// [`GraphqlEngine::from_manifest`] supplies this automatically from the - /// project manifest. This setter is intended for manually assembled + /// [`GraphqlEngine::from_schema_catalog`] supplies this automatically from the + /// read-model schema catalog. This setter is intended for manually assembled /// engines, which otherwise cannot export a client manifest safely. pub fn service_id(mut self, service_id: impl Into) -> Self { let service_id = service_id.into(); @@ -305,21 +305,17 @@ impl GraphqlEngineBuilder { } /// Register one exact named application surface for generated clients. - /// - /// `roles` is both the **eligible** principal set (who may open the - /// contract) and the **schema privilege** set (grant intersection for the - /// portable client schema). Prefer - /// [`Self::client_application_surface_with_schema_roles`] when elevated - /// principals must open a lower-privilege portable contract. + /// Both role sets are required: eligible principals may open the surface, + /// while schema roles determine its portable privilege intersection. /// /// The server still authorizes every request as its verified concrete role. pub fn client_application_surface( self, application: impl Into, - roles: impl IntoIterator>, + eligible_roles: impl IntoIterator>, + schema_roles: impl IntoIterator>, ) -> Self { - let roles = roles.into_iter().map(Into::into).collect::>(); - self.client_application_surface_with_schema_roles(application, roles.clone(), roles) + self.client_application_surface_with_schema_roles(application, eligible_roles, schema_roles) } /// Register an application surface with distinct eligible and schema roles. @@ -345,10 +341,18 @@ impl GraphqlEngineBuilder { .into_iter() .map(Into::into) .collect::>(); + let eligible_roles_were_unique = { + let mut sorted = eligible_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; + let schema_roles_were_unique = { + let mut sorted = schema_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; eligible_roles.sort(); - eligible_roles.dedup(); schema_roles.sort(); - schema_roles.dedup(); if application.is_empty() || application.len() > 128 || application.trim() != application @@ -372,12 +376,24 @@ impl GraphqlEngineBuilder { )); return self; } + if !eligible_roles_were_unique { + self.pending_errors.push(format!( + "GraphQL client application `{application}` eligible roles must be unique" + )); + return self; + } if schema_roles.is_empty() || schema_roles.iter().any(invalid_role) { self.pending_errors.push(format!( "GraphQL client application `{application}` must declare one or more bounded non-empty schema roles" )); return self; } + if !schema_roles_were_unique { + self.pending_errors.push(format!( + "GraphQL client application `{application}` schema roles must be unique" + )); + return self; + } if schema_roles .iter() .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) @@ -536,7 +552,7 @@ impl GraphqlEngineBuilder { } if self.protocol_token_key.is_some() && self.service_id.is_none() { return Err(GraphqlBuildError( - "GraphQL protocol tokens require a stable service ID; construct the engine with GraphqlEngine::from_manifest or GraphqlEngineBuilder::service_id" + "GraphQL protocol tokens require a stable service ID; construct the engine with GraphqlEngine::from_schema_catalog or GraphqlEngineBuilder::service_id" .into(), )); } @@ -902,7 +918,7 @@ impl GraphqlEngineBuilder { protocol_applications.insert( application.clone(), ProtocolApplicationInfo { - roles: registration.eligible_roles.clone(), + eligible_roles: registration.eligible_roles.clone(), schema_roles: registration.schema_roles.clone(), privilege_key, surface: ProtocolSurfaceInfo { diff --git a/src/graphql/engine/core.rs b/src/graphql/engine/core.rs index e11b7150..0ea7e051 100644 --- a/src/graphql/engine/core.rs +++ b/src/graphql/engine/core.rs @@ -185,7 +185,7 @@ pub(crate) struct ProtocolRoleInfo { #[derive(Clone)] pub(crate) struct ProtocolApplicationInfo { /// Eligible wire roles (who may open). - pub(crate) roles: Vec, + pub(crate) eligible_roles: Vec, /// Privilege packs for portable schema + server execution grants. #[allow(dead_code)] // inspected by tests / future multi-privilege diagnostics pub(crate) schema_roles: Vec, diff --git a/src/graphql/engine/mod.rs b/src/graphql/engine/mod.rs index 84e6cd8b..0c6dbb37 100644 --- a/src/graphql/engine/mod.rs +++ b/src/graphql/engine/mod.rs @@ -12,7 +12,7 @@ use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::manifest::DistributedProjectManifest; +use crate::table::ReadModelCatalog; use crate::microsvc::{Service, Session, ROLE_KEY, USER_ID_KEY}; use crate::read_model::{ReadModelChange, RelationalReadModelIncludes}; use crate::table::{ diff --git a/src/graphql/engine/protocol.rs b/src/graphql/engine/protocol.rs index a009e685..180577de 100644 --- a/src/graphql/engine/protocol.rs +++ b/src/graphql/engine/protocol.rs @@ -119,12 +119,17 @@ pub(crate) fn resolve_execution_authority( surface: ClientSurfaceIdentity::role(name), }) } - ClientSurfaceIdentity::Application { name, roles } => { + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { let runtime = inner.protocol.as_ref().ok_or(())?; let application = runtime.applications.get(&name).ok_or(())?; // Wire roles must equal the registered eligible set (canonical). - if roles != application.roles - || !principal_may_open_application(&asserted, &application.roles) + if eligible_roles != application.eligible_roles + || schema_roles != application.schema_roles + || !principal_may_open_application(&asserted, &application.eligible_roles) || requested.schema_hash != application.surface.schema_fingerprint { return Err(()); @@ -132,7 +137,11 @@ pub(crate) fn resolve_execution_authority( Ok(ExecutionAuthority { privilege_role: application.privilege_key.clone(), asserted_roles: asserted, - surface: ClientSurfaceIdentity::application(name, roles), + surface: ClientSurfaceIdentity::application_with_schema_roles( + name, + eligible_roles, + schema_roles, + ), }) } } @@ -161,13 +170,23 @@ pub(crate) fn select_protocol_surface<'a>( info.claim_keys.as_slice(), )) } - ClientSurfaceIdentity::Application { name, roles } => { + ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { let application = runtime.applications.get(name).ok_or(())?; - if roles != &application.roles { + if eligible_roles != &application.eligible_roles + || schema_roles != &application.schema_roles + { return Err(()); } Ok(( - ClientSurfaceIdentity::application(name.clone(), roles.clone()), + ClientSurfaceIdentity::application_with_schema_roles( + name.clone(), + eligible_roles.clone(), + schema_roles.clone(), + ), &application.surface, application.authorization_fingerprint.as_str(), application.claim_keys.as_slice(), diff --git a/src/graphql/engine/public_api.rs b/src/graphql/engine/public_api.rs index e78164c8..05e5889c 100644 --- a/src/graphql/engine/public_api.rs +++ b/src/graphql/engine/public_api.rs @@ -5,8 +5,8 @@ impl GraphqlEngine { GraphqlEngineBuilder::new(pool.into()) } - pub fn from_manifest( - m: &DistributedProjectManifest, + pub fn from_schema_catalog( + m: &ReadModelCatalog, pool: impl Into, ) -> Result { let mut builder = Self::builder(pool).service_id(m.name.clone()); @@ -49,7 +49,7 @@ impl GraphqlEngine { self.inner.role_surfaces.get(role).cloned() } - /// Stable service identity retained from [`DistributedProjectManifest::name`]. + /// Stable service identity retained from [`ReadModelCatalog::name`]. /// /// Engines built manually return `None` unless the builder opted in with /// [`GraphqlEngineBuilder::service_id`]. @@ -107,7 +107,8 @@ impl GraphqlEngine { pub fn client_surface_for_application( &self, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], ) -> Result { let service_id = self.client_export_service_id()?; let surface = self @@ -120,14 +121,19 @@ impl GraphqlEngine { "application surface `{application}` is not registered" )) })?; - let mut requested_roles = roles + let mut requested_eligible_roles = eligible_roles .iter() .map(|role| (*role).to_string()) .collect::>(); - requested_roles.sort(); - requested_roles.dedup(); + let mut requested_schema_roles = schema_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + requested_eligible_roles.sort(); + requested_schema_roles.sort(); let SurfaceSelection::Application { - roles: registered_roles, + eligible_roles: registered_roles, + schema_roles: registered_schema_roles, .. } = &surface.selection else { @@ -135,11 +141,15 @@ impl GraphqlEngine { "registered application surface `{application}` has invalid identity" ))); }; - if requested_roles != *registered_roles { + if requested_eligible_roles != *registered_roles + || requested_schema_roles != *registered_schema_roles + { return Err(ClientManifestError(format!( - "application surface `{application}` is registered for roles [{}], not [{}]", + "application surface `{application}` is registered for eligible roles [{}] and schema roles [{}], not eligible [{}] and schema [{}]", registered_roles.join(", "), - requested_roles.join(", ") + registered_schema_roles.join(", "), + requested_eligible_roles.join(", "), + requested_schema_roles.join(", ") ))); } DistributedClientSurfaceExport::from_selected_with_execution( @@ -157,16 +167,17 @@ impl GraphqlEngine { pub fn client_manifest_for_application( &self, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], ) -> Result { - self.client_surface_for_application(application, roles)? + self.client_surface_for_application(application, eligible_roles, schema_roles)? .manifest() } fn client_export_service_id(&self) -> Result { self.inner.service_id.clone().ok_or_else(|| { ClientManifestError( - "client export requires a service ID; construct the engine with GraphqlEngine::from_manifest or GraphqlEngineBuilder::service_id" + "client export requires a service ID; construct the engine with GraphqlEngine::from_schema_catalog or GraphqlEngineBuilder::service_id" .into(), ) }) diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index 31a07427..43fe6104 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -130,8 +130,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -146,8 +146,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user"); @@ -206,8 +206,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -231,12 +231,12 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") - .client_application_surface("console", ["user"]) + .client_application_surface("console", ["user"], ["user"]) .protocol_token_key([7; 32]) .graphiql(graphiql) .build() @@ -245,10 +245,10 @@ mod client_surface_parity_tests { let without_graphiql = build(false); let with_graphiql = build(true); let generated = without_graphiql - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); let runtime = with_graphiql - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); assert_eq!(generated, runtime); @@ -560,8 +560,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") @@ -585,18 +585,18 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["anonymous", "user"]) .grant_all("anonymous") .grant_all("user") - .client_application_surface("public", ["anonymous"]) + .client_application_surface("public", ["anonymous"], ["anonymous"]) .protocol_token_key([7; 32]) .build() .unwrap(); let manifest = engine - .client_manifest_for_application("public", &["anonymous"]) + .client_manifest_for_application("public", &["anonymous"], &["anonymous"]) .unwrap(); let request: Request = serde_json::from_value(serde_json::json!({ "query": "{ __typename }", @@ -606,7 +606,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "public", - "roles": ["anonymous"] + "eligible_roles": ["anonymous"], + "schema_roles": ["anonymous"] }, "schemaHash": manifest.schema_fingerprint } @@ -635,8 +636,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") @@ -656,11 +657,15 @@ mod client_surface_parity_tests { .unwrap(); let manifest = engine - .client_manifest_for_application("console", &["admin", "user"]) + .client_manifest_for_application("console", &["admin", "user"], &["user"]) .expect("registered multi-role application manifest"); assert_eq!( manifest.surface, - crate::graphql::ClientSurfaceIdentity::application("console", ["admin", "user"]) + crate::graphql::ClientSurfaceIdentity::application_with_schema_roles( + "console", + ["admin", "user"], + ["user"], + ) ); // Wire roles are eligible; schema privilege is user-only so x-user-id // remains a trusted preset (portable owner-style policy). @@ -695,7 +700,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": ["admin", "user"] + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"] }, "schemaHash": schema_hash } @@ -743,18 +749,18 @@ mod client_surface_parity_tests { let pool2 = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project2 = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let user_only = GraphqlEngine::from_manifest(&project2, pool2) + let project2 = ReadModelCatalog::new("orders-service").table_schema(orders()); + let user_only = GraphqlEngine::from_schema_catalog(&project2, pool2) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") .grant_all("user") - .client_application_surface("console", ["user"]) + .client_application_surface("console", ["user"], ["user"]) .protocol_token_key([7; 32]) .build() .unwrap(); let user_only_manifest = user_only - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .unwrap(); let user_only_request = || -> Request { serde_json::from_value(serde_json::json!({ @@ -765,7 +771,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": ["user"] + "eligible_roles": ["user"], + "schema_roles": ["user"] }, "schemaHash": user_only_manifest.schema_fingerprint } @@ -792,21 +799,21 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin", "user"]) .grant_all("admin") .grant_all("user") - .client_application_surface("console", ["admin", "user"]) + .client_application_surface("console", ["admin", "user"], ["admin", "user"]) .protocol_token_key([7; 32]) .build() .unwrap(); let manifest = engine - .client_manifest_for_application("console", &["user", "admin"]) + .client_manifest_for_application("console", &["user", "admin"], &["user", "admin"]) .expect("registered application manifest"); assert!(engine - .client_manifest_for_application("console", &["user"]) + .client_manifest_for_application("console", &["user"], &["user"]) .is_err()); let request = |schema_hash: &str, roles: serde_json::Value| -> Request { @@ -818,7 +825,8 @@ mod client_surface_parity_tests { "surface": { "kind": "application", "name": "console", - "roles": roles + "eligible_roles": roles, + "schema_roles": roles }, "schemaHash": schema_hash } @@ -1062,8 +1070,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let raw = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let raw = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1105,7 +1113,7 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); let commands = TypedCommandInventory::from_contracts(&[test_command::< ChangeOrderInput, ChangeOrderPayload, @@ -1115,7 +1123,7 @@ mod client_surface_parity_tests { &["user"], )]) .unwrap(); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1261,8 +1269,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["empty"]) .build() @@ -1289,8 +1297,8 @@ mod client_surface_parity_tests { let pool = sqlx::postgres::PgPoolOptions::new() .connect_lazy("postgres://postgres:postgres@localhost/distributed_test") .unwrap(); - let project = DistributedProjectManifest::new("orders-service").table_schema(orders()); - let engine = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("orders-service").table_schema(orders()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["user"]) .grant_all("user") @@ -1451,8 +1459,8 @@ mod client_surface_parity_tests { } } - fn matrix_project() -> DistributedProjectManifest { - DistributedProjectManifest::new("acceptance-service") + fn matrix_project() -> ReadModelCatalog { + ReadModelCatalog::new("acceptance-service") .table_schema(orders()) .table_schema(customers()) } @@ -1505,7 +1513,7 @@ mod client_surface_parity_tests { fn matrix_engine(pool: GraphqlPool) -> GraphqlEngine { let project = matrix_project(); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted", "admin"]) .default_limit(11) @@ -1567,7 +1575,7 @@ mod client_surface_parity_tests { other => panic!("unexpected matrix role `{other}`"), }; let selected = surface_for_role(&full, role, &grants).unwrap(); - DistributedClientSurfaceExport::from_project(&project, selected) + DistributedClientSurfaceExport::from_selected(project.name.clone(), selected) .unwrap() .manifest() .unwrap() @@ -1803,7 +1811,7 @@ mod client_surface_parity_tests { assert_eq!(response.errors.len(), 1, "{response:?}"); assert_eq!( response.errors[0].message, - "command dispatcher not configured (use graphql_router_with_service)" + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)" ); } @@ -1953,8 +1961,8 @@ mod client_surface_parity_tests { .await .unwrap(); let project = - DistributedProjectManifest::new("composite-service").table_schema(composite_records()); - let engine = GraphqlEngine::from_manifest(&project, pool.clone()) + ReadModelCatalog::new("composite-service").table_schema(composite_records()); + let engine = GraphqlEngine::from_schema_catalog(&project, pool.clone()) .unwrap() .roles(&["admin"]) .grant_all("admin") @@ -2169,10 +2177,10 @@ mod client_surface_parity_tests { .await .unwrap(); - let project = DistributedProjectManifest::new("relationship-policy-service") + let project = ReadModelCatalog::new("relationship-policy-service") .table_schema(policy_parents()) .table_schema(policy_children()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2256,10 +2264,10 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("composite-service") + let project = ReadModelCatalog::new("composite-service") .table_schema(composite) .table_schema(simple); - let error = GraphqlEngine::from_manifest(&project, pool) + let error = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["admin"]) .grant_all("admin") @@ -2303,8 +2311,8 @@ mod client_surface_parity_tests { .connect_lazy("sqlite::memory:") .unwrap(); let project = - DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2324,8 +2332,8 @@ mod client_surface_parity_tests { let pool = sqlx::sqlite::SqlitePoolOptions::new() .connect_lazy("sqlite::memory:") .unwrap(); - let project = DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + let project = ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( @@ -2379,8 +2387,8 @@ mod client_surface_parity_tests { .connect_lazy("sqlite::memory:") .unwrap(); let project = - DistributedProjectManifest::new("metrics-service").table_schema(metrics()); - let mut builder = GraphqlEngine::from_manifest(&project, pool) + ReadModelCatalog::new("metrics-service").table_schema(metrics()); + let mut builder = GraphqlEngine::from_schema_catalog(&project, pool) .unwrap() .roles(&["restricted"]); insert_permission( diff --git a/src/graphql/http.rs b/src/graphql/http.rs index b8717aa4..1b22be81 100644 --- a/src/graphql/http.rs +++ b/src/graphql/http.rs @@ -205,6 +205,10 @@ pub fn graphql_router(engine: Arc) -> Router { } /// GraphQL router that can dispatch command mutations through a [`Service`]. +/// +/// Prefer [`graphql_router_with_dispatcher`] for new hosts: local command +/// mounts are still Service-backed, but the public host API is the dispatcher +/// boundary rather than attaching `Service` directly. pub fn graphql_router_with_service(engine: Arc, service: Arc) -> Router { service .validate_graphql_engine(&engine) @@ -229,6 +233,19 @@ pub fn graphql_router_with_service(engine: Arc, service: Arc, + dispatcher: Arc, +) -> Router { + graphql_router_with_service(engine, Arc::clone(dispatcher.service())) +} + #[derive(Clone)] struct GraphqlHttpState { engine: Arc, diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 07bf7066..8110e2d9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -37,11 +37,12 @@ pub use command_contract::{ pub use command_contract::{ __command_projection_event_descriptor, __command_projection_event_preview, __command_projection_events, __command_projection_preview_constant, - __command_projection_state_preview, typed_command, Atomic, CommandConsistency, - CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, - CompiledDirectProjectionTarget, CompiledInputDefaults, Eventual, PrepareCommandError, - PreparedCommand, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, - TypedEffectRelationship, + __command_projection_state_preview, command_transition, typed_command, Atomic, + CommandConsistency, CommandEventSet, CommandOutcome, CommandProjectionEventSet, + CommandProjectionPreview, CommandProjectionPreviewSource, CommandProjectionPureArg, + CommandProjectionPureReduce, CompiledDirectProjectionTarget, CompiledInputDefaults, Eventual, + PrepareCommandError, PreparedCommand, Succeeded, TypedCommand, TypedEffectExpression, + TypedEffectKey, TypedEffectRelationship, }; pub use naming::{ aggregate_field, by_pk_field, comparison_op_fields, include_postgres_json_comparison_ops, @@ -96,7 +97,9 @@ pub use engine::{ GraphqlEngineBuilder, GraphqlPool, GraphqlPoolSource, }; #[cfg(feature = "graphql")] -pub use http::{graphiql_page, graphql_router, graphql_router_with_service}; +pub use http::{ + graphiql_page, graphql_router, graphql_router_with_dispatcher, graphql_router_with_service, +}; #[cfg(feature = "graphql")] pub use identity::{ extract_bearer, map_claims_to_session, public_oidc_identity_from_env, diff --git a/src/graphql/projection_delta/tests.rs b/src/graphql/projection_delta/tests.rs index df482ff4..fddf3dad 100644 --- a/src/graphql/projection_delta/tests.rs +++ b/src/graphql/projection_delta/tests.rs @@ -618,6 +618,7 @@ fn application_wire_surface_uses_authenticated_role_policy_for_actual_rows() { &surface, "delta-app", &["delta-admin".into(), "delta-user".into()], + &["delta-admin".into(), "delta-user".into()], &grants_by_role, ) .unwrap(); diff --git a/src/graphql/projection_delta/types.rs b/src/graphql/projection_delta/types.rs index 8880fdc8..8af91baa 100644 --- a/src/graphql/projection_delta/types.rs +++ b/src/graphql/projection_delta/types.rs @@ -100,7 +100,11 @@ pub struct ProjectionDeltaIdentity { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ProjectionDeltaSurfaceIdentity { Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } /// Exact selected program/binding compatibility pins. @@ -453,10 +457,15 @@ impl From<&crate::graphql::client_manifest::ClientSurfaceIdentity> crate::graphql::client_manifest::ClientSurfaceIdentity::Role { name } => { Self::Role { name: name.clone() } } - crate::graphql::client_manifest::ClientSurfaceIdentity::Application { name, roles } => { + crate::graphql::client_manifest::ClientSurfaceIdentity::Application { + name, + eligible_roles, + schema_roles, + } => { Self::Application { name: name.clone(), - roles: roles.clone(), + eligible_roles: eligible_roles.clone(), + schema_roles: schema_roles.clone(), } } } @@ -467,14 +476,33 @@ impl ProjectionDeltaSurfaceIdentity { fn validate(&self) -> Result<(), ProjectionDeltaError> { match self { Self::Role { name } => validate_identity("role surface", name), - Self::Application { name, roles } => { + Self::Application { + name, + eligible_roles, + schema_roles, + } => { validate_identity("application surface", name)?; - if roles.is_empty() { + if eligible_roles.is_empty() { + return Err(ProjectionDeltaError::InvalidIdentity { + field: "application eligible roles", + }); + } + validate_names("application eligible roles", eligible_roles)?; + if schema_roles.is_empty() { return Err(ProjectionDeltaError::InvalidIdentity { - field: "application roles", + field: "application schema roles", }); } - validate_names("application roles", roles) + validate_names("application schema roles", schema_roles)?; + if schema_roles + .iter() + .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) + { + return Err(ProjectionDeltaError::InvalidIdentity { + field: "application schema roles", + }); + } + Ok(()) } } } diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index c9ff8c76..0e69fc10 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -948,7 +948,7 @@ async fn resolve_command( let Some(service) = service else { return Err(client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_service)", + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", )); }; @@ -1041,7 +1041,7 @@ async fn resolve_command_status( let service = ctx.data_opt::>().ok_or_else(|| { client_error( "INTERNAL", - "command dispatcher not configured (use graphql_router_with_service)", + "command dispatcher not configured (use graphql_router_with_dispatcher or graphql_router_with_service)", ) })?; let command_id = ctx diff --git a/src/graphql/sdl.rs b/src/graphql/sdl.rs index e29f5382..5c736215 100644 --- a/src/graphql/sdl.rs +++ b/src/graphql/sdl.rs @@ -574,7 +574,7 @@ fn surface_arguments_sdl(arguments: &[super::surface::SurfaceArgument]) -> Strin format!("({arguments})") } -/// Filter operational tables and render SDL for a project manifest's tables. +/// Filter operational tables and render SDL for a read-model schema catalog. pub fn graphql_sdl_from_schemas( schemas: impl IntoIterator, ) -> Result { diff --git a/src/graphql/surface/application.rs b/src/graphql/surface/application.rs index 77ba0edc..9cbd4044 100644 --- a/src/graphql/surface/application.rs +++ b/src/graphql/surface/application.rs @@ -545,7 +545,7 @@ pub(in crate::graphql::surface) fn validate_surface_filter( } /// Build an explicit named application surface as the structural intersection -/// of all runtime roles it supports (eligible roles = schema roles). +/// of its declared schema roles, with a separate eligible opener set. /// /// A missing role declaration is an error rather than an accidental empty or /// admin surface. Commands must be granted to every schema role; differing row @@ -557,10 +557,17 @@ pub(in crate::graphql::surface) fn validate_surface_filter( pub fn surface_for_application( surface: &Surface, application: &str, - roles: &[String], + eligible_roles: &[String], + schema_roles: &[String], grants_by_role: &BTreeMap>, ) -> Result { - surface_for_application_contract(surface, application, roles, roles, grants_by_role) + surface_for_application_contract( + surface, + application, + eligible_roles, + schema_roles, + grants_by_role, + ) } /// Build an application surface with distinct **eligible** and **schema** roles. @@ -585,20 +592,48 @@ pub fn surface_for_application_contract( } let mut eligible_roles = eligible_roles.to_vec(); let mut schema_roles = schema_roles.to_vec(); + if eligible_roles.iter().any(|role| role.trim().is_empty()) { + return Err(format!( + "application surface `{application}` eligible roles must be nonempty" + )); + } + if schema_roles.iter().any(|role| role.trim().is_empty()) { + return Err(format!( + "application surface `{application}` schema roles must be nonempty" + )); + } + let eligible_roles_were_unique = { + let mut sorted = eligible_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; + let schema_roles_were_unique = { + let mut sorted = schema_roles.clone(); + sorted.sort(); + sorted.windows(2).all(|roles| roles[0] != roles[1]) + }; eligible_roles.sort(); - eligible_roles.dedup(); schema_roles.sort(); - schema_roles.dedup(); if eligible_roles.is_empty() { return Err(format!( "application surface `{application}` must declare at least one eligible role" )); } + if !eligible_roles_were_unique { + return Err(format!( + "application surface `{application}` eligible roles must be unique" + )); + } if schema_roles.is_empty() { return Err(format!( "application surface `{application}` must declare at least one schema role" )); } + if !schema_roles_were_unique { + return Err(format!( + "application surface `{application}` schema roles must be unique" + )); + } if schema_roles .iter() .any(|role| !eligible_roles.iter().any(|eligible| eligible == role)) @@ -688,7 +723,8 @@ pub fn surface_for_application_contract( .sort_by(|a, b| a.command_name.cmp(&b.command_name)); selected.selection = SurfaceSelection::Application { name: application.to_string(), - roles: eligible_roles, + eligible_roles, + schema_roles, }; Ok(selected) } diff --git a/src/graphql/surface/projections.rs b/src/graphql/surface/projections.rs index 423f2c09..b1b53fd0 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -15,7 +15,7 @@ use super::types::{SurfaceProjectionOwner, SurfaceProjectionOwnerKind}; use super::{SurfaceModel, SurfaceRelationshipKeys}; /// One role-safe selected operation from an authoritative projection program. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceProjectionOperation { pub operation_id: String, pub staging_ordinal: u32, @@ -31,7 +31,7 @@ pub(crate) struct SurfaceProjectionOperation { /// One exact selected event arm. Its selector is server-only; client export /// receives only a digest-derived event reference. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceProjectionArm { pub arm_id: String, pub selector: crate::ProjectionEventSelector, @@ -39,7 +39,7 @@ pub(crate) struct SurfaceProjectionArm { } /// Role-safe program inventory retained after Surface selection. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) struct SurfaceSelectedProjectionProgram { pub name: String, pub version: u64, @@ -112,6 +112,48 @@ impl std::fmt::Debug for SurfaceModeledProjection { } impl SurfaceModeledProjection { + /// Return the complete portable behavior contract for this selected + /// registration. Executor routes, server executor closures, and physical + /// topology are intentionally excluded; the program IR and binding + /// compatibility fields remain canonical identity material. + pub(crate) fn canonical_contract_value(&self) -> Result { + let program = if let Some(raw_program) = &self.raw_program { + serde_json::to_value(raw_program).map_err(|error| error.to_string())? + } else if let Some(selected) = &self.selected { + serde_json::to_value(selected).map_err(|error| error.to_string())? + } else { + return Err("modeled projection has no canonical program material".to_owned()); + }; + let binding = self.raw_binding.as_ref().map(|binding| { + serde_json::json!({ + "identity_version": binding.identity_version(), + "program_ir_version": binding.program_ir_version(), + "operation_semantics_version": binding.operation_semantics_version(), + "program_id": binding.program_id().to_string(), + "events": binding.events(), + "source": binding.source(), + "owner": binding.owner(), + "placement": binding.placement(), + "execution_class": binding.execution_class(), + "partition": binding.partition(), + "outputs": binding.outputs(), + "relationships": binding.relationships(), + }) + }); + Ok(serde_json::json!({ + "program_id": self.program_id.to_string(), + "binding_id": self.binding_id.to_string(), + "owner": self.owner, + "placement": self.placement, + "execution_class": self.execution_class, + "state": self.state, + "epoch": self.epoch.as_str(), + "output_models": self.output_models, + "program": program, + "binding": binding, + })) + } + #[cfg(test)] pub(crate) fn selected_for_client_manifest_test( program_id: ProjectionProgramId, diff --git a/src/graphql/surface/tests.rs b/src/graphql/surface/tests.rs index 61f9005c..65cae0d3 100644 --- a/src/graphql/surface/tests.rs +++ b/src/graphql/surface/tests.rs @@ -778,7 +778,7 @@ fn selected_surfaces_reject_command_and_projector_reattachment() { let grants_by_role = BTreeMap::from([("user".into(), grants)]); let application = - surface_for_application(&full, "web", &["user".into()], &grants_by_role).unwrap(); + surface_for_application(&full, "web", &["user".into()], &["user".into()], &grants_by_role).unwrap(); assert!(application .clone() .with_typed_commands(&TypedCommandInventory::empty()) diff --git a/src/graphql/surface/types.rs b/src/graphql/surface/types.rs index 30f9f520..2b8dbf3a 100644 --- a/src/graphql/surface/types.rs +++ b/src/graphql/surface/types.rs @@ -61,7 +61,7 @@ pub enum SurfaceRowPolicy { } /// Semantic category for one GraphQL field argument. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum SurfaceArgumentKind { Filter, Order, @@ -71,7 +71,7 @@ pub enum SurfaceArgumentKind { } /// One accepted root/relationship argument from the shared Surface IR. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SurfaceArgument { pub name: String, pub kind: SurfaceArgumentKind, @@ -105,7 +105,7 @@ pub struct RootField { } /// Column field on an object type (after skips / role filter). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ColumnField { pub name: String, pub scalar: String, @@ -129,7 +129,7 @@ pub struct RelField { pub aggregate: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SurfaceRelationshipAggregate { pub name: String, pub type_name: String, @@ -565,10 +565,168 @@ impl std::fmt::Debug for Surface { pub(crate) enum SurfaceSelection { Catalog, Role { name: String }, - Application { name: String, roles: Vec }, + Application { + name: String, + eligible_roles: Vec, + schema_roles: Vec, + }, } impl Surface { + /// Serialize the complete behavior-affecting Surface IR once. Application + /// manifests, SDL metadata, and client exports use this snapshot as their + /// shared identity input; none of them re-walks a table catalog. + pub(crate) fn canonical_contract_value(&self) -> Result { + let selection = match &self.selection { + SurfaceSelection::Catalog => serde_json::json!({"kind": "catalog"}), + SurfaceSelection::Role { name } => serde_json::json!({"kind": "role", "name": name}), + SurfaceSelection::Application { + name, + eligible_roles, + schema_roles, + } => { + let mut eligible_roles = eligible_roles.clone(); + let mut schema_roles = schema_roles.clone(); + eligible_roles.sort(); + eligible_roles.dedup(); + schema_roles.sort(); + schema_roles.dedup(); + serde_json::json!({ + "kind": "application", + "name": name, + "eligible_roles": eligible_roles, + "schema_roles": schema_roles, + }) + } + }; + let models = self + .models + .values() + .map(|model| { + let mut columns = model.columns.clone(); + columns.sort_by(|left, right| left.name.cmp(&right.name)); + let mut primary_key = model.primary_key.clone(); + primary_key.sort(); + let mut relationships = model + .relationships + .iter() + .map(|relationship| { + serde_json::json!({ + "name": relationship.name, + "target_model": relationship.target_model, + "target_object": relationship.target_object, + "kind": format!("{:?}", relationship.kind).to_ascii_lowercase(), + "list": relationship.list, + "nullable": relationship.nullable, + "arguments": relationship + .arguments + .iter() + .map(argument_value) + .collect::>(), + "keys": relationship_keys_value(&relationship.keys), + "dependencies": relationship.dependencies, + "aggregate": relationship.aggregate.as_ref().map(|aggregate| { + serde_json::json!({ + "name": aggregate.name, + "type_name": aggregate.type_name, + "arguments": aggregate + .arguments + .iter() + .map(argument_value) + .collect::>(), + "dependencies": aggregate.dependencies, + }) + }), + }) + }) + .collect::>(); + relationships.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + serde_json::json!({ + "model_name": model.model_name, + "table_name": model.table_name, + "object_name": model.object_name, + "columns": columns, + "relationships": relationships, + "primary_key": primary_key, + "row_policy": row_policy_value(&model.row_policy), + "role_limit": model.role_limit, + "aggregations": model.aggregations, + }) + }) + .collect::>(); + let mut roots = self + .query_fields + .iter() + .map(|root| root_value("query", root)) + .chain(self.subscription_fields.iter().map(|root| root_value("subscription", root))) + .collect::>(); + roots.sort_by(|left, right| { + (left["operation"].as_str(), left["name"].as_str()) + .cmp(&(right["operation"].as_str(), right["name"].as_str())) + }); + let mut commands = self + .commands + .iter() + .map(|command| { + serde_json::json!({ + "command_name": command.command_name, + "field_name": command.field_name, + "roles": command.roles, + "input": command_shape_value(&command.input), + "output": command_shape_value(&command.output), + "consistency": command.consistency, + "input_defaults": command.input_defaults, + "effects": command.effects, + "confirmations": command.confirmations, + "projected_model": command.projected_model.as_ref().map(|model| model.model.clone()), + "direct_projection": command.direct_projection.as_ref().map(|target| target.canonical_value()), + "projections": command.projections, + "confirmation_unavailable": command.confirmation_unavailable, + }) + }) + .collect::>(); + commands.sort_by(|left, right| left["command_name"].as_str().cmp(&right["command_name"].as_str())); + let mut projectors = self + .projectors + .iter() + .map(|owner| { + let modeled = owner + .modeled + .iter() + .map(|modeled| modeled.canonical_contract_value()) + .collect::, _>>()?; + Ok::<_, String>(serde_json::json!({ + "name": owner.name, + "facts": owner.facts, + "models": owner.models, + "dependencies": owner.dependencies, + "change_epoch": owner.change_epoch, + "partition": owner.partition, + "kind": if owner.is_direct() { "direct" } else { "async" }, + "modeled": modeled, + })) + }) + .collect::, _>>()?; + projectors.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + let value = serde_json::json!({ + "version": 1, + "selection": selection, + "dialect": format!("{:?}", self.dialect).to_ascii_lowercase(), + "aggregates": self.aggregates, + "subscriptions": self.subscriptions, + "default_limit": self.default_limit, + "max_limit": self.max_limit, + "models": models, + "roots": roots, + "comparison_ops": self.comparison_ops, + "commands": commands, + "commands_attached": self.commands_attached, + "projectors": projectors, + "projectors_attached": self.projectors_attached, + }); + Ok(crate::application::canonical_json(&value)) + } + /// Inventory of query root field names (sorted). pub fn query_root_names(&self) -> Vec<&str> { let mut names: Vec<&str> = self.query_fields.iter().map(|f| f.name.as_str()).collect(); @@ -602,8 +760,9 @@ impl Surface { } /// Attach the crate-private typed command inventory to this unselected - /// catalog surface. Public callers derive this exclusively via - /// [`Surface::with_service`]. + /// catalog surface. The inventory must be bound before authorization + /// selection so role/application filtering operates on the declaration-owned + /// typed command contracts. pub(crate) fn with_typed_commands( mut self, commands: &crate::graphql::commands::TypedCommandInventory, @@ -636,6 +795,41 @@ impl Surface { Ok(self) } + /// Bind one explicit logical module's retained typed command contracts to + /// this unselected catalog Surface without constructing executable runtime + /// state. The module definitions are the authoritative source; public + /// command JSON is never used to reconstruct typed shapes or effects. + pub fn with_module(self, module: &crate::application::Module) -> Result { + self.with_modules(std::iter::once(module)) + } + + /// Bind several explicit logical modules before role/application + /// authorization selection. + pub fn with_modules<'a, I>( + mut self, + modules: I, + ) -> Result + where + I: IntoIterator, + { + if !matches!(self.selection, SurfaceSelection::Catalog) { + return Err( + "module commands can only be attached to the unselected catalog Surface before authorization selection" + .into(), + ); + } + if self.commands_attached { + return Err("a command registry has already been attached to this Surface".into()); + } + let mut contracts = Vec::new(); + for module in modules { + contracts.extend(module.typed_command_contracts()?); + } + let inventory = crate::graphql::commands::TypedCommandInventory::from_contracts(&contracts)?; + self = self.with_typed_commands(&inventory)?; + Ok(self) + } + /// Pool-free authoritative typed command path. The executable Routes /// inventory supplies both GraphQL declarations and non-forgeable service /// provenance used by static client export. @@ -814,3 +1008,105 @@ impl Surface { Ok(self) } } + +fn root_value(operation: &str, root: &RootField) -> serde_json::Value { + serde_json::json!({ + "operation": operation, + "name": root.name, + "kind": match root.kind { + RootKind::List => "list", + RootKind::ByPk => "by_pk", + RootKind::Aggregate => "aggregate", + }, + "object": root.object, + "model_name": root.model_name, + "arguments": root.arguments.iter().map(argument_value).collect::>(), + "dependencies": root.dependencies, + "default_limit": root.default_limit, + "max_limit": root.max_limit, + }) +} + +fn argument_value(argument: &SurfaceArgument) -> serde_json::Value { + serde_json::json!({ + "name": argument.name, + "kind": argument_kind_name(argument.kind), + "type_name": argument.type_name, + "nullable": argument.nullable, + "list": argument.list, + }) +} + +fn argument_kind_name(kind: SurfaceArgumentKind) -> &'static str { + match kind { + SurfaceArgumentKind::Filter => "filter", + SurfaceArgumentKind::Order => "order", + SurfaceArgumentKind::Limit => "limit", + SurfaceArgumentKind::Offset => "offset", + SurfaceArgumentKind::PrimaryKey => "primary_key", + } +} + +fn command_shape_value(shape: &SurfaceCommandShape) -> serde_json::Value { + match shape { + SurfaceCommandShape::None => serde_json::Value::Null, + SurfaceCommandShape::Typed(definition) => type_def_value(definition), + } +} + +fn type_def_value(definition: &SurfaceTypeDef) -> serde_json::Value { + serde_json::json!({ + "name": definition.name, + "fields": definition.fields.iter().map(|field| serde_json::json!({ + "name": field.name, + "type_name": field.type_name, + "nullable": field.nullable, + "list": field.list, + "item_nullable": field.item_nullable, + "nested": field.nested.as_deref().map(type_def_value), + })).collect::>(), + }) +} + +fn row_policy_value(policy: &SurfaceRowPolicy) -> serde_json::Value { + match policy { + SurfaceRowPolicy::Unrestricted => serde_json::json!({"kind": "unrestricted"}), + SurfaceRowPolicy::Predicate(predicate) => { + serde_json::json!({"kind": "predicate", "expression": predicate}) + } + SurfaceRowPolicy::ServerOnly => serde_json::json!({"kind": "server_only"}), + } +} + +fn relationship_keys_value(keys: &SurfaceRelationshipKeys) -> serde_json::Value { + match keys { + SurfaceRelationshipKeys::Direct { local, remote } => { + serde_json::json!({"kind": "direct", "local": local, "remote": remote}) + } + SurfaceRelationshipKeys::Through { + local, + remote, + table, + source_foreign_key, + target_foreign_key, + } => serde_json::json!({ + "kind": "through", + "local": local, + "remote": remote, + "table": table, + "source_foreign_key": source_foreign_key, + "target_foreign_key": target_foreign_key, + }), + SurfaceRelationshipKeys::ThroughOpaque { + local, + remote, + dependency, + } => serde_json::json!({ + "kind": "through_opaque", + "local": local, + "remote": remote, + "dependency": dependency, + }), + SurfaceRelationshipKeys::Embedded => serde_json::json!({"kind": "embedded"}), + } +} diff --git a/src/lib.rs b/src/lib.rs index 170c3a96..cb5c5c3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,17 @@ // Allow proc-macros to reference this crate by name even when used internally extern crate self as distributed; +/// Macro implementation support. External packages may rename the +/// distributed dependency and re-export it under the generated path; the +/// generated code must not require a direct serde dependency of its own. +#[doc(hidden)] +pub mod __private { + pub use serde; +} + pub mod aggregate; +pub mod application; +pub mod command_dispatch; pub mod bus; pub mod domain_event; pub mod entity; @@ -24,7 +34,6 @@ pub mod emitter; pub mod graphql; mod in_memory_repo; pub mod lock; -pub mod manifest; #[cfg(feature = "metrics")] pub mod metrics; pub mod microsvc; @@ -53,6 +62,21 @@ pub use entity::{ BITCODE_PAYLOAD_CODEC_VERSION, }; +// Placement-independent application composition. The module path remains the +// canonical namespace; these common contract types are also convenient at the +// crate root for contract-only packages. +pub use application::{ + Application, ApplicationError, ApplicationManifest, CommandMount, CommandMountHandler, + CommandMountRegistrar, CommandSpec, ContractCompiler, DeploymentPlan, LogicalId, Module, + ModuleManifest, MountSelector, ProcessIntent, ProcessPreset, ProjectionSpec, SurfaceSpec, + APPLICATION_MANIFEST_SCHEMA_VERSION, DEPLOYMENT_PLAN_SCHEMA_VERSION, +}; +pub use command_dispatch::{ + CommandDispatchEnvelope, CommandDispatchError, CommandDispatchReceipt, CommandDispatcher, + LocalCommandDispatcher, RemoteCommandDispatcher, RemoteDispatchConfig, RemoteTrustMode, + SharedCommandDispatcher, APPROVED_REMOTE_DISPATCH_PROFILE, COMMAND_DISPATCH_ENVELOPE_VERSION, +}; + // Domain events: typed outward contracts distinct from replay events/snapshots. pub use domain_event::{ DomainDeletion, DomainDeletionError, DomainEvent, DomainEventBodyDescriptor, @@ -65,7 +89,7 @@ pub use domain_event::{ // Logical projection contracts. Physical read-model lowering deliberately lives // behind adapters and is not part of this semantic surface. -pub use projection::{ +pub use projection::{LocalProjectionMounts, LocalProjectionMountsBuilder, ProjectionArm, ProjectionAssignment, ProjectionEnvelopeField, ProjectionEventSelector, ProjectionEventSet, ProjectionExpression, ProjectionField, ProjectionInvalidation, ProjectionKeyField, ProjectionMutationKind, ProjectionMutationProvenance, @@ -366,13 +390,8 @@ pub use table::{ TableIndex, TableKind, TableMigrationArtifact, TableModel, TableMutation, TableRowMutation, TableSchema, TableSchemaAdapter, TableSchemaAdapterCapabilities, TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, TableSchemaRegistryExt, - TableSchemaVerification, TableStoreError, TableWritePlan, DEFAULT_TABLE_VERSION_COLUMN, -}; - -pub use manifest::{ - DistributedManifestEnvelope, DistributedProjectManifest, MessageEndpointManifest, - MetricsEndpointManifest, ServiceManifest, ServiceObservabilityManifest, TraceExportMode, - TracePropagationMode, TracingManifest, TransportManifest, DISTRIBUTED_MANIFEST_SCHEMA_VERSION, + ReadModelCatalog, TableSchemaVerification, TableStoreError, TableWritePlan, + DEFAULT_TABLE_VERSION_COLUMN, }; pub use trace_context::{ is_valid_traceparent, TraceContext, CAUSATION_ID, CORRELATION_ID, TRACEPARENT, TRACESTATE, @@ -404,15 +423,20 @@ macro_rules! graphql_models { } // Session convenience re-exports used by GraphQL permission filters. -pub use microsvc::{ROLE_KEY, USER_ID_KEY}; +pub use microsvc::{ + MessageEndpointDescriptor, MetricsEndpointDescriptor, ROLE_KEY, ServiceDescriptor, + ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, + TransportDescriptor, USER_ID_KEY, +}; // Re-export proc macros. The old event-owning projection proc-macro and // separately authored `command_effects!` / `command_confirmations!` are gone. // Use `mutation!` / `mutation_file!` + declarative `projection!` (event→mutation // mount); commands predict events via `.emits`/`.preview`. pub use distributed_macros::{ - aggregate, command_input_defaults, digest, mutation, mutation_file, sourced, DomainEvent, - DomainState, GraphqlInput, GraphqlOutput, ReadModel, Snapshot, + aggregate, application, command, command_input_defaults, digest, module, mutation, + mutation_file, sourced, DomainEvent, DomainState, GraphqlInput, GraphqlOutput, ReadModel, + Snapshot, }; // Re-export enqueue macro (requires "emitter" feature) diff --git a/src/manifest.rs b/src/manifest.rs deleted file mode 100644 index 75360766..00000000 --- a/src/manifest.rs +++ /dev/null @@ -1,376 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::table::{ - generate_table_migration_artifacts, table_schema_statements, TableSchema, TableSchemaRegistry, - TableSqlDialect, -}; -use crate::{RelationalReadModel, TableMigrationArtifact, TableStoreError}; - -pub const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u32 = 1; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DistributedManifestEnvelope { - pub schema_version: u32, - pub project: DistributedProjectManifest, -} - -impl DistributedManifestEnvelope { - pub fn new(project: DistributedProjectManifest) -> Self { - Self { - schema_version: DISTRIBUTED_MANIFEST_SCHEMA_VERSION, - project, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DistributedProjectManifest { - pub name: String, - pub tables: Vec, - pub services: Vec, -} - -impl DistributedProjectManifest { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - tables: Vec::new(), - services: Vec::new(), - } - } - - pub fn read_model(mut self) -> Self - where - M: RelationalReadModel, - { - self.try_register_read_model::() - .expect("read model schema should be valid in distributed manifest"); - self - } - - pub fn try_read_model(mut self) -> Result - where - M: RelationalReadModel, - { - self.try_register_read_model::()?; - Ok(self) - } - - pub fn try_register_read_model(&mut self) -> Result<&mut Self, TableStoreError> - where - M: RelationalReadModel, - { - self.try_register_table_schema(M::schema().clone()) - } - - pub fn table_schema(mut self, schema: TableSchema) -> Self { - self.try_register_table_schema(schema) - .expect("table schema should be valid in distributed manifest"); - self - } - - pub fn try_table_schema(mut self, schema: TableSchema) -> Result { - self.try_register_table_schema(schema)?; - Ok(self) - } - - pub fn try_register_table_schema( - &mut self, - schema: TableSchema, - ) -> Result<&mut Self, TableStoreError> { - let mut registry = self.table_registry()?; - registry.register_schema(schema.clone())?; - self.tables.push(schema); - Ok(self) - } - - pub fn service(mut self, service: ServiceManifest) -> Self { - self.services.push(service); - self - } - - pub fn table_registry(&self) -> Result { - let mut registry = TableSchemaRegistry::new(); - for schema in &self.tables { - registry.register_schema(schema.clone())?; - } - Ok(registry) - } - - pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, TableStoreError> { - table_schema_statements(&self.table_registry()?, dialect) - } - - pub fn sql_migration_artifacts( - &self, - dialect: TableSqlDialect, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(&self.table_registry()?, dialect) - } - - pub fn envelope(self) -> DistributedManifestEnvelope { - DistributedManifestEnvelope::new(self) - } - - /// Render the dialect-independent GraphQL SDL artifact for all - /// [`TableKind::ReadModel`] tables in this manifest. - pub fn graphql_sdl(&self) -> Result { - crate::graphql::graphql_sdl_for_tables(&self.tables) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServiceManifest { - pub name: String, - pub commands: Vec, - pub events: Vec, - pub transports: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observability: Option, -} - -impl ServiceManifest { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - commands: Vec::new(), - events: Vec::new(), - transports: Vec::new(), - observability: None, - } - } - - pub fn command(mut self, name: impl Into) -> Self { - self.commands.push(MessageEndpointManifest::new(name)); - self - } - - pub fn event(mut self, name: impl Into) -> Self { - self.events.push(MessageEndpointManifest::new(name)); - self - } - - pub fn transport(mut self, kind: impl Into) -> Self { - self.transports.push(TransportManifest::new(kind)); - self - } - - pub fn observability(mut self, observability: ServiceObservabilityManifest) -> Self { - self.observability = Some(observability); - self - } - - pub fn metrics(mut self, metrics: MetricsEndpointManifest) -> Self { - let mut observability = self.observability.unwrap_or_default(); - observability.metrics = Some(metrics); - self.observability = Some(observability); - self - } - - pub fn tracing(mut self, tracing: TracingManifest) -> Self { - let mut observability = self.observability.unwrap_or_default(); - observability.tracing = Some(tracing); - self.observability = Some(observability); - self - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServiceObservabilityManifest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metrics: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tracing: Option, -} - -impl ServiceObservabilityManifest { - pub fn new() -> Self { - Self::default() - } - - pub fn metrics(mut self, metrics: MetricsEndpointManifest) -> Self { - self.metrics = Some(metrics); - self - } - - pub fn tracing(mut self, tracing: TracingManifest) -> Self { - self.tracing = Some(tracing); - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct MetricsEndpointManifest { - pub path: String, - pub port_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub interval: Option, -} - -impl MetricsEndpointManifest { - pub fn new(path: impl Into, port_name: impl Into) -> Self { - Self { - path: path.into(), - port_name: port_name.into(), - interval: None, - } - } - - pub fn prometheus_default() -> Self { - Self::new("/metrics", "http").interval("30s") - } - - pub fn interval(mut self, interval: impl Into) -> Self { - self.interval = Some(interval.into()); - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TracingManifest { - pub propagation: TracePropagationMode, - pub export: TraceExportMode, -} - -impl TracingManifest { - pub fn otlp() -> Self { - Self { - propagation: TracePropagationMode::W3cTraceContext, - export: TraceExportMode::Otlp, - } - } - - pub fn disabled() -> Self { - Self { - propagation: TracePropagationMode::Disabled, - export: TraceExportMode::Disabled, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TracePropagationMode { - #[default] - W3cTraceContext, - Disabled, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TraceExportMode { - #[default] - Otlp, - Disabled, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct MessageEndpointManifest { - pub name: String, -} - -impl MessageEndpointManifest { - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TransportManifest { - pub kind: String, -} - -impl TransportManifest { - pub fn new(kind: impl Into) -> Self { - Self { kind: kind.into() } - } - - pub fn http() -> Self { - Self::new("http") - } -} - -#[cfg(test)] -mod tests { - use serde::{Deserialize, Serialize}; - - use super::*; - use crate::{outbox_message_schema, ReadModel}; - - #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] - #[table("orders")] - struct OrderView { - #[id("order_id")] - order_id: String, - status: String, - } - - #[test] - fn manifest_collects_schema_service_metadata_and_renders_sql() { - let manifest = DistributedProjectManifest::new("checkout") - .read_model::() - .table_schema(outbox_message_schema().clone()) - .service( - ServiceManifest::new("checkout-saga") - .command("checkout.start") - .event("seat.reserved") - .transport("http"), - ); - - let envelope = DistributedManifestEnvelope::new(manifest.clone()); - let json = serde_json::to_string(&envelope).expect("manifest should serialize"); - assert!(json.contains("\"schema_version\":1")); - assert!(json.contains("\"table_name\":\"orders\"")); - - let restored: DistributedManifestEnvelope = - serde_json::from_str(&json).expect("manifest should deserialize"); - assert_eq!(restored.project.name, "checkout"); - assert_eq!(restored.project.tables.len(), 2); - assert_eq!( - restored.project.services[0].commands[0].name, - "checkout.start" - ); - - let sql = manifest - .sql_statements(TableSqlDialect::Postgres) - .expect("manifest SQL should render") - .join("\n"); - assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"orders\"")); - assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"outbox_messages\"")); - } - - #[test] - fn service_manifest_serializes_observability_metadata_when_declared() { - let service = ServiceManifest::new("checkout-saga") - .metrics(MetricsEndpointManifest::prometheus_default()) - .tracing(TracingManifest::otlp()); - - let json = serde_json::to_string(&service).expect("service manifest should serialize"); - assert!(json.contains("\"observability\"")); - assert!(json.contains("\"path\":\"/metrics\"")); - assert!(json.contains("\"propagation\":\"w3c_trace_context\"")); - - let restored: ServiceManifest = - serde_json::from_str(&json).expect("service manifest should deserialize"); - let observability = restored - .observability - .expect("observability should deserialize"); - assert_eq!( - observability.metrics.expect("metrics").port_name, - "http".to_string() - ); - assert_eq!( - observability.tracing.expect("tracing").export, - TraceExportMode::Otlp - ); - } - - #[test] - fn service_manifest_observability_is_optional_for_older_json() { - let json = r#"{"name":"checkout-saga","commands":[],"events":[],"transports":[]}"#; - let restored: ServiceManifest = - serde_json::from_str(json).expect("older service manifest should deserialize"); - - assert!(restored.observability.is_none()); - } -} diff --git a/src/microsvc/descriptor.rs b/src/microsvc/descriptor.rs new file mode 100644 index 00000000..6551c290 --- /dev/null +++ b/src/microsvc/descriptor.rs @@ -0,0 +1,175 @@ +use serde::{Deserialize, Serialize}; + +/// Deployment/service endpoint metadata kept outside the logical application +/// contract. It is consumed by service tooling and never serialized into an +/// [`crate::application::ApplicationManifest`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceDescriptor { + pub name: String, + pub commands: Vec, + pub events: Vec, + pub transports: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observability: Option, +} + +impl ServiceDescriptor { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + commands: Vec::new(), + events: Vec::new(), + transports: Vec::new(), + observability: None, + } + } + + pub fn command(mut self, name: impl Into) -> Self { + self.commands.push(MessageEndpointDescriptor::new(name)); + self + } + + pub fn event(mut self, name: impl Into) -> Self { + self.events.push(MessageEndpointDescriptor::new(name)); + self + } + + pub fn transport(mut self, kind: impl Into) -> Self { + self.transports.push(TransportDescriptor::new(kind)); + self + } + + pub fn observability(mut self, observability: ServiceObservabilityDescriptor) -> Self { + self.observability = Some(observability); + self + } + + pub fn metrics(mut self, metrics: MetricsEndpointDescriptor) -> Self { + let mut observability = self.observability.unwrap_or_default(); + observability.metrics = Some(metrics); + self.observability = Some(observability); + self + } + + pub fn tracing(mut self, tracing: TracingDescriptor) -> Self { + let mut observability = self.observability.unwrap_or_default(); + observability.tracing = Some(tracing); + self.observability = Some(observability); + self + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceObservabilityDescriptor { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracing: Option, +} + +impl ServiceObservabilityDescriptor { + pub fn new() -> Self { + Self::default() + } + + pub fn metrics(mut self, metrics: MetricsEndpointDescriptor) -> Self { + self.metrics = Some(metrics); + self + } + + pub fn tracing(mut self, tracing: TracingDescriptor) -> Self { + self.tracing = Some(tracing); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MetricsEndpointDescriptor { + pub path: String, + pub port_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval: Option, +} + +impl MetricsEndpointDescriptor { + pub fn new(path: impl Into, port_name: impl Into) -> Self { + Self { + path: path.into(), + port_name: port_name.into(), + interval: None, + } + } + + pub fn prometheus_default() -> Self { + Self::new("/metrics", "http").interval("30s") + } + + pub fn interval(mut self, interval: impl Into) -> Self { + self.interval = Some(interval.into()); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TracingDescriptor { + pub propagation: TracePropagationMode, + pub export: TraceExportMode, +} + +impl TracingDescriptor { + pub fn otlp() -> Self { + Self { + propagation: TracePropagationMode::W3cTraceContext, + export: TraceExportMode::Otlp, + } + } + + pub fn disabled() -> Self { + Self { + propagation: TracePropagationMode::Disabled, + export: TraceExportMode::Disabled, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TracePropagationMode { + #[default] + W3cTraceContext, + Disabled, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceExportMode { + #[default] + Otlp, + Disabled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MessageEndpointDescriptor { + pub name: String, +} + +impl MessageEndpointDescriptor { + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportDescriptor { + pub kind: String, +} + +impl TransportDescriptor { + pub fn new(kind: impl Into) -> Self { + Self { kind: kind.into() } + } + + pub fn http() -> Self { + Self::new("http") + } +} diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 4d5ddd50..45d0d6c3 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -57,6 +57,7 @@ mod causal; mod context; +mod descriptor; mod dependencies; mod error; mod message_router; @@ -64,6 +65,18 @@ mod projector; mod runtime; mod service; mod session; +// Worker loops need a Tokio runtime (spawn/sleep); only compile when a feature +// that enables the optional `tokio` dep is active (default feature set does not). +#[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", +))] +mod workers; pub use crate::bus::{Message, MessageKind, PayloadDecodeError, SubscriptionPlan}; pub use causal::AggregateCheckout; @@ -74,6 +87,11 @@ pub use dependencies::{ HasRepo, ReadModelStoreDependencies, RepoDependencies, RepoReadModelDependencies, }; pub use error::HandlerError; +pub use descriptor::{ + MessageEndpointDescriptor, MetricsEndpointDescriptor, ServiceDescriptor, + ServiceObservabilityDescriptor, TraceExportMode, TracePropagationMode, TracingDescriptor, + TransportDescriptor, +}; pub use projector::{ CausalProjectorContext, CausalProjectorRouteBuilder, LoadedProjection, ProjectionRepairHandle, ProjectionRepairHandleParseError, @@ -85,6 +103,16 @@ pub use runtime::{DEFAULT_MAX_PUBLISH_ATTEMPTS, DEFAULT_PUBLISH_LEASE}; pub(crate) use service::CausalCommandProjectionEvidence; #[cfg(feature = "graphql")] pub use service::GraphqlServiceBindError; +#[cfg(any( + feature = "http", + feature = "grpc", + feature = "postgres", + feature = "sqlite", + feature = "nats", + feature = "rabbitmq", + feature = "kafka", +))] +pub use workers::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; pub use service::{ direct_read_model, CausalCommandContext, CausalCommitBuilder, CausalRepository, CommandRequest, CommandResponse, DeliveryKind, DirectReadModelProjection, HandlerNames, HandlerSpec, @@ -93,7 +121,8 @@ pub use service::{ #[cfg(feature = "graphql")] pub(crate) use service::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalProjectionEvidenceState, }; pub use session::{Session, ROLE_KEY, USER_ID_KEY}; diff --git a/src/microsvc/service/defaults.rs b/src/microsvc/service/defaults.rs index d4cfbc60..c8a172bf 100644 --- a/src/microsvc/service/defaults.rs +++ b/src/microsvc/service/defaults.rs @@ -16,6 +16,41 @@ impl Routes<()> { Self::from_dependencies(()) } + /// Framework helper: wire an aggregate repository + read-model store without + /// application code naming `QueuedRepository` / `RepoReadModelDependencies`. + /// + /// Product modules should start here, then register `typed_command` mounts. + pub fn for_aggregate( + repo: R, + locks: L, + read_models: S, + ) -> Routes< + RepoReadModelDependencies< + crate::AggregateRepository, A>, + S, + >, + > + where + R: crate::GetStream + crate::TransactionalCommit + Clone + Send + Sync + 'static, + L: crate::LockManager + Clone + 'static, + A: crate::Aggregate + Send + Sync + 'static, + S: HasReadModelStore + Send + Sync + 'static, + crate::QueuedRepository: Clone + + crate::AggregateBuilder + + HasOutboxStore + + crate::TransactionalCommit + + Send + + Sync + + 'static, + crate::AggregateRepository, A>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + { + use crate::{AggregateBuilder, Queueable}; + Routes::new() + .with_repo(repo.queued_with(locks).aggregate::()) + .with_read_model_store(read_models) + } + /// Use any custom dependency value for this route bundle. pub fn with_dependencies(self, dependencies: D) -> Routes where diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index 5f7a17e3..cc3d892d 100644 --- a/src/microsvc/service/handlers.rs +++ b/src/microsvc/service/handlers.rs @@ -575,9 +575,15 @@ where self.message.trace_context() } + /// Session for this command attempt (transport/gateway claims). + pub fn session(&self) -> &Session { + self.session + } + pub fn user_id(&self) -> Result<&str, HandlerError> { self.session .user_id() + .filter(|s| !s.is_empty()) .ok_or_else(|| HandlerError::Unauthorized("missing user ID in session".into())) } diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index a01aec1c..1b571832 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -42,7 +42,8 @@ pub use causal::GraphqlServiceBindError; #[cfg(feature = "graphql")] pub(crate) use causal::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, - CausalCommandReceiptSource, CausalProjectionEvidenceState, + CausalCommandReceiptSource, CausalDispatchError, CausalDispatchResult, + CausalProjectionEvidenceState, }; #[allow(unused_imports)] // public API surface for handler-owned projected commits pub use handlers::StagedProjectedRow; diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 40055b02..4bded259 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -21,6 +21,7 @@ use super::handlers::{ ProjectorBootstrapFuture, }; use crate::aggregate::Aggregate; +use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; use crate::bus::{Bus, Message, MessageKind, MessagePublisher, OrderedDelivery, TransportError}; #[cfg(feature = "graphql")] use crate::command_ledger::{ @@ -31,12 +32,16 @@ use crate::command_ledger::{ }; #[cfg(feature = "graphql")] use crate::graphql::command_contract::CommandConsistency; -use crate::graphql::command_contract::{CommandOutcome, TypedCommandContract}; +use crate::graphql::command_contract::{ + CommandEventSet, CommandOutcome, CompiledInputDefaults, TypedCommandContract, +}; #[cfg(feature = "graphql")] use crate::graphql::command_input::canonicalize_command_input; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; -use crate::graphql::{SurfaceProjector, TypedCommand}; +use crate::graphql::{ + command_transition, GraphqlInputType, SurfaceProjector, TypedCommand, +}; #[cfg(feature = "graphql")] use crate::microsvc::causal::CausalWorkspace; use crate::microsvc::context::Context; @@ -433,6 +438,7 @@ pub struct TypedRouteBuilder { routes: Routes, route_name: &'static str, contract: TypedCommandContract, + mount: Option, _types: std::marker::PhantomData K>, } @@ -457,6 +463,55 @@ impl RouteBuilder { } } +impl TypedRouteBuilder +where + K: CommandOutcome, +{ + /// Override the GraphQL mutation field name (defaults from the command id). + #[must_use] + pub fn field_name(mut self, field_name: impl Into) -> Self { + self.contract.field_name = field_name.into(); + self + } + + /// Restrict the command to these surface roles. + #[must_use] + pub fn roles(mut self, roles: impl IntoIterator>) -> Self { + self.contract.roles = roles.into_iter().map(Into::into).collect(); + self.contract.roles.sort(); + self.contract.roles.dedup(); + self + } + + /// Declare values generated once into the canonical command input before + /// dispatch. + #[must_use] + pub fn input_defaults(mut self, defaults: CompiledInputDefaults) -> Self { + self.contract.input_defaults = defaults.0; + self.contract + .input_defaults + .sort_by(|left, right| left.path.cmp(&right.path)); + self + } + + /// Pure reducer over a known cache row for client auto-optimism. + #[must_use] + pub fn preview_reduce_known_record( + mut self, + reduce: crate::graphql::CommandProjectionPureReduce, + ) -> Self { + self.contract.projections.add_pure_reduce(reduce); + self + } + + /// Attach the mount produced by the same command declaration. The route + /// adapter checks its identity before registration. + pub fn mount(mut self, mount: CommandMount) -> Self { + self.mount = Some(mount); + self + } +} + impl TypedRouteBuilder where D: CausalRouteDependencies + Send + Sync + 'static, @@ -472,6 +527,7 @@ where self.routes.register_typed_handler( self.route_name, self.contract, + self.mount, None, boxed_prepared_handler(handler), ) @@ -487,6 +543,7 @@ where self.routes.register_typed_handler( self.route_name, self.contract, + self.mount, Some(guard), boxed_prepared_handler(handler), ) @@ -501,6 +558,7 @@ pub struct Routes { pub(super) dependencies: D, handlers: HashMap>>, handler_specs: Vec, + command_mounts: Vec, projectors: Vec>>, modeled_local_services: BTreeSet, outbox_configurator: Option>, @@ -513,6 +571,7 @@ impl Routes { dependencies, handlers: HashMap::new(), handler_specs: Vec::new(), + command_mounts: Vec::new(), projectors: Vec::new(), modeled_local_services: BTreeSet::new(), outbox_configurator: None, @@ -531,6 +590,7 @@ impl Routes { assert!( self.handlers.is_empty() && self.handler_specs.is_empty() + && self.command_mounts.is_empty() && self.projectors.is_empty() && self.modeled_local_services.is_empty(), "Routes::{builder} must be called before registering handlers" @@ -575,10 +635,45 @@ impl Routes { routes: self, route_name, contract, + mount: None, _types: std::marker::PhantomData, } } + /// Register a typed command whose outward emit set comes from a domain + /// transition witness (`domain_commands::*` or a domain-event marker). + /// + /// Equivalent to + /// [`typed_command`](Self::typed_command)`(command_transition::(name))` + /// so callers do not also declare `.emits` / `.emits_events`. + /// + /// Chain `.field_name`, `.roles`, `.input_defaults`, then `.handle`. + pub fn command_transition(self, name: &'static str) -> TypedRouteBuilder + where + S: CommandEventSet, + I: GraphqlInputType + serde::de::DeserializeOwned + Send + 'static, + K: CommandOutcome, + { + self.typed_command(command_transition::(name)) + } + + /// Compile the registered typed declarations into portable command specs. + pub fn command_specs(&self) -> crate::application::ApplicationResult> { + let mut specs = self + .typed_contracts() + .into_iter() + .map(CommandSpec::from_contract) + .collect::>>()?; + specs.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(specs) + } + + /// Executable mounts created when typed handlers are registered. These + /// mounts are never part of a serialized application manifest. + pub fn command_mounts(&self) -> &[CommandMount] { + &self.command_mounts + } + /// Register one typed, ordered causal projector using the exact /// [`SurfaceProjector`] declaration also supplied to the GraphQL engine. pub fn causal_projector( @@ -751,6 +846,7 @@ impl Routes { mut self, route_name: &'static str, contract: TypedCommandContract, + declared_mount: Option, guard: Option>>, handle: Arc>, ) -> Self @@ -771,12 +867,30 @@ impl Routes { MessageKind::Command, route_name, ); + let spec = CommandSpec::from_contract(&contract) + .unwrap_or_else(|error| panic!("typed command contract cannot compile: {error}")); + let mount = declared_mount.unwrap_or_else(|| { + CommandMount::from_typed_route(spec.clone(), route_name) + }); + assert_eq!( + mount.spec().id, + spec.id, + "typed command mount and route declaration ids must match" + ); + assert_eq!( + mount.spec().fingerprint, + spec.fingerprint, + "typed command mount and route declaration fingerprints must match" + ); by_name.insert( route_name.to_string(), RegisteredHandler::Causal(Box::new( RegisteredCausalHandler::::new(contract, guard, handle), )), ); + mount + .register_with(&mut self) + .unwrap_or_else(|error| panic!("typed command mount registration failed: {error}")); self.handler_specs.push(HandlerSpec::command(route_name)); self } @@ -910,6 +1024,59 @@ impl Routes { } } +impl CommandMountRegistrar for Routes { + fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), crate::microsvc::HandlerError> { + let Some(handlers) = self.handlers.get(&MessageKind::Command) else { + return Err(crate::microsvc::HandlerError::UnknownCommand( + mount.spec().id.clone(), + )); + }; + if !handlers.contains_key(&mount.spec().id) { + return Err(crate::microsvc::HandlerError::UnknownCommand( + mount.spec().id.clone(), + )); + } + if mount.typed_route_name() != Some(mount.spec().id.as_str()) { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is not the generated typed-route registration for its declaration", + mount.spec().id + ))); + } + let expected = self + .typed_contracts() + .into_iter() + .find(|contract| contract.name == mount.spec().id) + .and_then(|contract| CommandSpec::from_contract(contract).ok()) + .ok_or_else(|| { + crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is not backed by a typed causal declaration", + mount.spec().id + )) + })?; + if expected.fingerprint != mount.spec().fingerprint { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` has a stale declaration fingerprint", + mount.spec().id + ))); + } + if self + .command_mounts + .iter() + .any(|registered| registered.spec().id == mount.spec().id) + { + return Err(crate::microsvc::HandlerError::Rejected(format!( + "command mount `{}` is registered more than once", + mount.spec().id + ))); + } + self.command_mounts.push(mount); + Ok(()) + } +} + impl ErasedCausalHandler for RegisteredCausalHandler where D: CausalRouteDependencies + Send + Sync + 'static, diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index b7817e45..522ec6a1 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -18,6 +18,7 @@ use super::helpers::{ use super::helpers::{microsvc_dispatch_span, microsvc_handler_span}; use super::request::{CommandRequest, CommandResponse}; use super::routes::{CausalCommandPolicy, DynBusPublisher, ErasedRoutes, HandlerSpec, Routes}; +use crate::application::{CommandMount, CommandMountRegistrar, CommandSpec}; use crate::bus::{ Message, MessageKind, OrderedDelivery, RunOptions, SubscriptionPlan, TransportError, }; @@ -49,6 +50,7 @@ pub struct Service { handler_specs: Vec, causal_command_policy: CausalCommandPolicy, runner: Option, + registered_command_mounts: Vec, /// When false, HTTP does not mount `POST /{command}` (GraphQL / health only). /// Commands remain dispatchable via GraphQL mutations and in-process `dispatch`. http_command_routes: bool, @@ -66,6 +68,7 @@ impl Service { handler_specs: Vec::new(), causal_command_policy: CausalCommandPolicy::default(), runner: None, + registered_command_mounts: Vec::new(), http_command_routes: true, #[cfg(feature = "graphql")] graphql: None, @@ -299,6 +302,169 @@ impl Service { self.name.as_deref() } + /// Exact generated mounts registered with this service. The returned + /// values contain portable identity only; typed execution still requires + /// the authenticated causal dispatch path. + pub fn registered_command_mounts(&self) -> &[CommandMount] { + &self.registered_command_mounts + } + + /// Register one explicit command mount against the already-installed + /// typed route inventory. The route's canonical command spec is the only + /// authority; a stale or lookalike mount is rejected before dispatch. + pub fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), HandlerError> { + self.register_command_mount_inner(mount) + } + + /// Invoke a registered mount through the service adapter. The request + /// still enters the normal transport boundary, so typed causal routes + /// retain their authentication/receipt/projection-proof requirements. + pub async fn dispatch_mount( + &self, + mount: &CommandMount, + request: &CommandRequest, + ) -> CommandResponse { + match self.dispatch_mount_result(mount, request).await { + Ok(response) => response, + Err(error) => CommandResponse { + status: error.status_code(), + body: serde_json::json!({ "error": error.to_string() }), + }, + } + } + + pub(crate) async fn dispatch_mount_result( + &self, + mount: &CommandMount, + request: &CommandRequest, + ) -> Result { + if request.command != mount.spec().id { + return Err(HandlerError::Rejected(format!( + "command request `{}` does not match mount `{}`", + request.command, + mount.spec().id + ))); + } + if !self + .registered_command_mounts + .iter() + .any(|registered| registered.spec().id == mount.spec().id + && registered.spec().fingerprint == mount.spec().fingerprint) + { + return Err(HandlerError::Rejected( + "command mount was not registered against this service".into(), + )); + } + if mount.typed_route_name().is_some() { + return Err(HandlerError::Unauthorized( + "typed command mounts require the authenticated causal dispatch adapter".into(), + )); + } + mount.invoke(request).await + } + + #[cfg(feature = "graphql")] + #[allow(dead_code)] + pub(crate) async fn dispatch_registered_mount_causally( + &self, + mount: &CommandMount, + request: &CommandRequest, + command_id: &str, + session: Session, + principal: VerifiedPrincipal, + ) -> Result { + self.ensure_registered_mount(mount) + .map_err(CausalDispatchError::Handler)?; + if mount.typed_route_name() != Some(request.command.as_str()) { + return Err(CausalDispatchError::BadRequest( + "typed command mount route identity does not match the request".into(), + )); + } + self.dispatch_causal_with_receipt( + &request.command, + command_id, + request.input.clone(), + session, + principal, + ) + .await + } + + fn register_command_mount_inner( + &mut self, + mount: CommandMount, + ) -> Result<(), HandlerError> { + let Some(indices) = self + .index + .get(&MessageKind::Command) + .and_then(|commands| commands.get(&mount.spec().id)) + else { + return Err(HandlerError::UnknownCommand(mount.spec().id.clone())); + }; + if indices.len() != 1 { + return Err(HandlerError::Rejected(format!( + "command mount `{}` has ambiguous service routes", + mount.spec().id + ))); + } + if mount.typed_route_name() != Some(mount.spec().id.as_str()) { + return Err(HandlerError::Rejected(format!( + "command mount `{}` is not the generated typed-route registration for its declaration", + mount.spec().id + ))); + } + let expected = self + .routes + .get(indices[0]) + .and_then(|routes| { + routes + .typed_command_contracts() + .into_iter() + .find(|contract| contract.name == mount.spec().id) + .and_then(|contract| CommandSpec::from_contract(contract).ok()) + }) + .ok_or_else(|| { + HandlerError::Rejected(format!( + "command mount `{}` is not backed by a typed causal route", + mount.spec().id + )) + })?; + if expected.fingerprint != mount.spec().fingerprint { + return Err(HandlerError::Rejected(format!( + "command mount `{}` has a stale declaration fingerprint", + mount.spec().id + ))); + } + if self.registered_command_mounts.iter().any(|registered| { + registered.spec().id == mount.spec().id + }) { + return Err(HandlerError::Rejected(format!( + "command mount `{}` is registered more than once", + mount.spec().id + ))); + } + self.registered_command_mounts.push(mount); + Ok(()) + } + + #[cfg(feature = "graphql")] + #[allow(dead_code)] + fn ensure_registered_mount(&self, mount: &CommandMount) -> Result<(), HandlerError> { + if self.registered_command_mounts.iter().any(|registered| { + registered.spec().id == mount.spec().id + && registered.spec().fingerprint == mount.spec().fingerprint + }) { + Ok(()) + } else { + Err(HandlerError::Rejected( + "command mount was not registered against this service".into(), + )) + } + } + /// Install the bus run behavior (used by `with_bus`). pub(crate) fn set_runner(&mut self, runner: ServiceRunner) { self.runner = Some(runner); @@ -343,6 +509,7 @@ impl Service { .into_iter() .cloned() .collect::>(); + let command_mounts = routes.command_mounts().to_vec(); #[cfg(feature = "graphql")] assert!( self.graphql.is_none() || typed_commands.is_empty(), @@ -390,6 +557,7 @@ impl Service { .push(route_index); } self.handler_specs.extend_from_slice(routes.handler_specs()); + self.registered_command_mounts.extend(command_mounts); self.routes.push(Box::new(routes)); } @@ -401,6 +569,18 @@ impl Service { .collect() } + /// Compile every explicitly registered typed command into portable specs. + /// The returned values contain no Service, repository, or handler pointer. + pub fn command_specs(&self) -> crate::application::ApplicationResult> { + let mut specs = self + .typed_command_contracts() + .iter() + .map(crate::application::CommandSpec::from_contract) + .collect::>>()?; + specs.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(specs) + } + pub(crate) fn typed_command_binding(&self) -> Result { let service_id = self .name() @@ -970,6 +1150,50 @@ impl Service { } } +impl CommandMountRegistrar for Service { + fn register_command_mount( + &mut self, + mount: CommandMount, + ) -> Result<(), HandlerError> { + self.register_command_mount_inner(mount) + } +} + +impl crate::application::CommandMountExecution for Service { + fn invoke_mount<'a>( + &'a self, + mount: &'a CommandMount, + request: CommandRequest, + invocation: crate::application::CommandMountInvocation, + ) -> crate::application::CommandMountExecutionFuture<'a> { + Box::pin(async move { + match invocation { + crate::application::CommandMountInvocation::Transport => self + .dispatch_mount_result(mount, &request) + .await + .map(crate::application::CommandMountExecutionResult::Transport) + .map_err(crate::application::CommandMountExecutionError::Handler), + #[cfg(feature = "graphql")] + crate::application::CommandMountInvocation::Authenticated { + command_id, + session, + principal, + } => self + .dispatch_registered_mount_causally( + mount, + &request, + &command_id, + session, + principal, + ) + .await + .map(crate::application::CommandMountExecutionResult::Causal) + .map_err(crate::application::CommandMountExecutionError::Causal), + } + }) + } +} + pub(super) fn validate_projector_registrations<'a>( registrations: impl IntoIterator, ) { diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index e2009625..51b79c7c 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -643,6 +643,24 @@ async fn typed_handler( Ok(PreparedCommand::prepare(TypedOutput { id: input.id }).unwrap()) } +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +static GENERATED_MOUNT_HANDLER_INVOKED: AtomicUsize = AtomicUsize::new(0); + +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +#[distributed::command( + id = "causal.generated_mount", + roles(user), + input = CausalTestInput, + outcome = Succeeded +)] +async fn generated_mount_handler( + _context: &CausalCommandContext<'_, CausalDispatcherAggregate>, + input: CausalTestInput, +) -> Result>, HandlerError> { + GENERATED_MOUNT_HANDLER_INVOKED.fetch_add(1, Ordering::SeqCst); + Ok(PreparedCommand::prepare(TypedOutput { id: input.id }).unwrap()) +} + #[derive(Default)] struct RouteComboAggregate { entity: Entity, @@ -1111,6 +1129,49 @@ async fn typed_direct_dispatch_fails_before_invoking_guard_or_handler() { assert!(!TYPED_HANDLER_INVOKED.load(Ordering::SeqCst)); } +#[cfg(all(feature = "graphql", feature = "application-runtime"))] +#[tokio::test] +async fn generated_mount_registers_and_executes_original_handler_through_causal_protocol() { + GENERATED_MOUNT_HANDLER_INVOKED.store(0, Ordering::SeqCst); + let repository = InMemoryRepository::new(); + let routes = generated_mount_handler_register( + Routes::new().with_repo(repository.aggregate::()), + ); + let service = Service::new().named("generated-mounts").routes(routes); + + assert_eq!(service.registered_command_mounts().len(), 1); + assert_eq!( + service.registered_command_mounts()[0].spec().id, + GENERATED_MOUNT_HANDLER_MOUNT.spec().id + ); + + let request = CommandRequest { + command: "causal.generated_mount".into(), + input: json!({"id": "generated-1", "label": "mounted"}), + session_variables: HashMap::new(), + }; + let result = service + .registered_command_mounts()[0] + .invoke_with( + &service, + &request, + crate::application::CommandMountInvocation::Authenticated { + command_id: causal_test_command_id(), + session: session_with_role("user"), + principal: causal_test_principal(), + }, + ) + .await; + let crate::application::CommandMountExecutionResult::Causal(result) = result + .expect("authenticated causal mount dispatch should commit") + else { + panic!("typed mount must use the causal execution result"); + }; + assert_eq!(result.payload, json!({"id": "generated-1"})); + assert_eq!(result.receipt.command_name, "causal.generated_mount"); + assert_eq!(GENERATED_MOUNT_HANDLER_INVOKED.load(Ordering::SeqCst), 1); +} + #[cfg(feature = "graphql")] #[tokio::test] async fn causal_dispatch_replays_canonical_equivalent_input_without_reinvoking_handler() { diff --git a/src/microsvc/workers.rs b/src/microsvc/workers.rs new file mode 100644 index 00000000..532337f7 --- /dev/null +++ b/src/microsvc/workers.rs @@ -0,0 +1,63 @@ +//! Framework-owned outbox and consumer worker loops. +//! +//! Applications should not reimplement dialect-specific spawn loops. + +use std::sync::Arc; +use std::time::Duration; + +use crate::bus::{Bus, RunOptions}; +use crate::microsvc::Service; +use crate::outbox_worker::{BusPublisher, OutboxDispatcher, OutboxStore}; + +/// Spawn the standard outbox publish loop for a bus-backed store. +pub fn spawn_outbox_publish_loop( + store: S, + bus: Arc, + service_name: impl Into, + lease: Duration, + max_attempts: u32, +) where + S: OutboxStore + 'static, + B: Bus + Send + Sync + 'static, +{ + let service_name = service_name.into(); + tokio::spawn(async move { + let dispatcher = OutboxDispatcher::new( + store, + BusPublisher::new(bus), + format!("outbox:{}", std::process::id()), + lease, + max_attempts, + ) + .with_service(service_name); + loop { + match dispatcher.dispatch_batch(32).await { + Ok(o) if o.published > 0 || o.claimed > 0 => {} + Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(e) => { + eprintln!("outbox: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); +} + +/// Spawn a service consumer loop that re-runs the bus handler continuously. +pub fn spawn_service_consumer_loop(build_service: F) +where + F: Fn() -> Service + Send + Sync + 'static, +{ + tokio::spawn(async move { + loop { + let service = build_service(); + match service.run(RunOptions::idempotent()).await { + Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(e) => { + eprintln!("consumer: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); +} diff --git a/src/postgres_repo/mod.rs b/src/postgres_repo/mod.rs index c15c2183..041ba1f9 100644 --- a/src/postgres_repo/mod.rs +++ b/src/postgres_repo/mod.rs @@ -20,7 +20,7 @@ use crate::repository::RepositoryError; use crate::sqlx_repo::read_model::quote_identifier; use crate::sqlx_repo::repo::{ embedded_migrator, outbox_message_from_row, system_time_epoch_secs, SqlxOutboxStore, - SqlxRepository, + SqlxRepository, POSTGRES_MIGRATIONS, }; use crate::sqlx_repo::{ self, is_postgres_unique_violation, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, @@ -32,30 +32,8 @@ use crate::table::{ ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError, }; -static POSTGRES_MIGRATOR: LazyLock = LazyLock::new(|| { - embedded_migrator(&[ - ( - 1, - "initial", - include_str!("../../migrations/postgres/0001_initial.sql"), - ), - ( - 2, - "command ledger", - include_str!("../../migrations/postgres/0002_command_ledger.sql"), - ), - ( - 3, - "projection protocol", - include_str!("../../migrations/postgres/0003_projection_protocol.sql"), - ), - ( - 4, - "command ledger atomic state", - include_str!("../../migrations/postgres/0004_command_ledger_atomic_state.sql"), - ), - ]) -}); +static POSTGRES_MIGRATOR: LazyLock = + LazyLock::new(|| embedded_migrator(POSTGRES_MIGRATIONS)); const POSTGRES_BACKEND: &str = "postgres"; const BIGINT_STORAGE: &str = "bigint storage"; diff --git a/src/projection/local_mounts.rs b/src/projection/local_mounts.rs new file mode 100644 index 00000000..e5b44fa9 --- /dev/null +++ b/src/projection/local_mounts.rs @@ -0,0 +1,288 @@ +//! Framework-owned local projection mount compilation. +//! +//! Applications declare program descriptors, read models, and epochs. Physical +//! topology, partition codecs, catalog activation, and Surface packaging are +//! derived here. + +#![allow(missing_docs)] + + +use crate::graphql::{SurfaceDirectProjection, SurfaceModeledProjection, SurfaceProjector}; +use crate::projection::catalog::{ + ActiveProjectionBindings, ProjectionBindingActivation, ProjectionCatalog, +}; +use crate::projection::lower::ProjectionDescriptor; +use crate::projection::placement::{ + ProjectionBinding, ProjectionBindingState, ProjectionEpoch, ProjectionExecutorRoute, + ProjectionOutput, ProjectionOwner, ProjectionPhysicalTopology, ProjectionSourceBinding, + PROJECTION_PARTITION_CODEC_VERSION, +}; +use crate::projection_protocol::ProjectorTopologyId; +use crate::table::TableSchema; +use crate::RelationalReadModel; + +/// One eventual surface projector ready for GraphQL/service mounts. +#[derive(Clone)] +pub struct LocalEventualMount { + pub owner: String, + pub projector: SurfaceProjector, +} + +/// One direct surface projection ready for GraphQL/service mounts. +#[derive(Clone)] +pub struct LocalDirectMount { + pub owner: String, + pub projection: SurfaceDirectProjection, +} + +/// Compiled set of local projection mounts for one application. +#[derive(Clone, Default)] +pub struct LocalProjectionMounts { + pub eventual: Vec, + pub direct: Vec, +} + +impl LocalProjectionMounts { + pub fn projector(&self, owner: &str) -> Option { + self.eventual + .iter() + .find(|mount| mount.owner == owner) + .map(|mount| mount.projector.clone()) + } + + pub fn direct_projection(&self, owner: &str) -> Option { + self.direct + .iter() + .find(|mount| mount.owner == owner) + .map(|mount| mount.projection.clone()) + } + + pub fn all_owners(&self) -> Vec { + let mut owners = Vec::new(); + for mount in &self.eventual { + owners.push(mount.projector.clone().into()); + } + for mount in &self.direct { + owners.push(mount.projection.clone().into()); + } + owners + } +} + +struct PendingEventual { + owner: String, + epoch: String, + binding: ProjectionBinding, + modeled_factory: Box< + dyn Fn( + &ProjectionCatalog, + &ActiveProjectionBindings, + &ProjectionBinding, + ) -> Result + + Send + + Sync, + >, +} + +struct PendingDirect { + owner: String, + epoch: String, + binding: ProjectionBinding, + modeled_factory: Box< + dyn Fn( + &ProjectionCatalog, + &ActiveProjectionBindings, + &ProjectionBinding, + ) -> Result + + Send + + Sync, + >, +} + +/// Builder that materializes a shared catalog and Surface mounts for local hosting. +pub struct LocalProjectionMountsBuilder { + service_id: String, + source: ProjectionSourceBinding, + eventual: Vec, + direct: Vec, +} + +impl LocalProjectionMountsBuilder { + /// `domain_source` is a stable domain event stream name (e.g. `ordered-domain-events`). + pub fn new( + service_id: impl Into, + domain_source: impl Into, + ) -> Result { + let service_id = service_id.into(); + let source = ProjectionSourceBinding::try_new( + format!("{service_id}-domain"), + domain_source, + 1, + ) + .map_err(|error| error.to_string())?; + Ok(Self { + service_id, + source, + eventual: Vec::new(), + direct: Vec::new(), + }) + } + + /// Register an eventual projection program targeting model `M`. + pub fn eventual_model( + mut self, + owner: impl Into, + descriptor: ProjectionDescriptor, + epoch: impl Into, + ) -> Result + where + M: RelationalReadModel, + D: Copy + 'static, + { + let owner = owner.into(); + let epoch = epoch.into(); + let digest = stable_topology_digest(&owner); + let binding = ProjectionBinding::materialize_eventual( + descriptor.eventual(), + self.source.clone(), + ProjectionOwner::try_new(owner.clone()).map_err(|e| e.to_string())?, + "distributed-projection-partition", + PROJECTION_PARTITION_CODEC_VERSION, + vec![projection_output_for::()?], + Vec::new(), + Some(physical_topology(&owner, digest)), + ) + .map_err(|error| error.to_string())?; + self.eventual.push(PendingEventual { + owner, + epoch, + binding, + modeled_factory: Box::new(move |catalog, active, binding| { + SurfaceModeledProjection::try_from_descriptor( + descriptor, + catalog, + active, + binding.id(), + ) + }), + }); + Ok(self) + } + + /// Register a direct projection program targeting model `M`. + pub fn direct_model( + mut self, + owner: impl Into, + descriptor: ProjectionDescriptor, + epoch: impl Into, + ) -> Result + where + M: RelationalReadModel, + { + let owner = owner.into(); + let epoch = epoch.into(); + let digest = stable_topology_digest(&owner); + let binding = ProjectionBinding::materialize_direct( + descriptor.direct(), + self.source.clone(), + ProjectionOwner::try_new(owner.clone()).map_err(|e| e.to_string())?, + "distributed-projection-partition", + PROJECTION_PARTITION_CODEC_VERSION, + vec![projection_output_for::()?], + Vec::new(), + Some(physical_topology(&owner, digest)), + ) + .map_err(|error| error.to_string())?; + self.direct.push(PendingDirect { + owner, + epoch, + binding, + modeled_factory: Box::new(move |catalog, active, binding| { + SurfaceModeledProjection::try_from_descriptor( + descriptor, + catalog, + active, + binding.id(), + ) + }), + }); + Ok(self) + } + + pub fn build(self) -> Result { + let mut bindings = Vec::new(); + for entry in &self.eventual { + bindings.push(entry.binding.clone()); + } + for entry in &self.direct { + bindings.push(entry.binding.clone()); + } + let catalog = ProjectionCatalog::try_new(bindings).map_err(|e| e.to_string())?; + let mut activations = Vec::new(); + for entry in &self.eventual { + activations.push(activation(&entry.binding, &entry.epoch, &self.service_id)?); + } + for entry in &self.direct { + activations.push(activation(&entry.binding, &entry.epoch, &self.service_id)?); + } + let active = catalog + .activate(activations, None) + .map_err(|e| e.to_string())?; + + let mut mounts = LocalProjectionMounts::default(); + for entry in &self.eventual { + let modeled = (entry.modeled_factory)(&catalog, &active, &entry.binding)?; + mounts.eventual.push(LocalEventualMount { + owner: entry.owner.clone(), + projector: SurfaceProjector::new(entry.owner.clone()).modeled(modeled), + }); + } + for entry in &self.direct { + let modeled = (entry.modeled_factory)(&catalog, &active, &entry.binding)?; + mounts.direct.push(LocalDirectMount { + owner: entry.owner.clone(), + projection: SurfaceDirectProjection::new(entry.owner.clone()).modeled(modeled), + }); + } + Ok(mounts) + } +} + +fn activation( + binding: &ProjectionBinding, + epoch: &str, + service_id: &str, +) -> Result { + Ok(ProjectionBindingActivation::new( + binding.id(), + binding.program_id(), + ProjectionEpoch::new(epoch).map_err(|e| e.to_string())?, + ProjectionBindingState::Active, + Some(ProjectionExecutorRoute::local(service_id).map_err(|e| e.to_string())?), + )) +} + +fn projection_output_for() -> Result { + let schema: TableSchema = M::schema().clone(); + ProjectionOutput::try_new(schema.model_name.clone(), schema.table_name.clone(), schema) + .map_err(|e| e.to_string()) +} + +fn physical_topology(name: &str, digest: u8) -> ProjectionPhysicalTopology { + ProjectionPhysicalTopology::from_protocol( + &ProjectorTopologyId::new(1, name, [digest; 32]) + .expect("canonical local projection topology"), + ) +} + +fn stable_topology_digest(owner: &str) -> u8 { + let mut acc = 0u8; + for (i, b) in owner.as_bytes().iter().enumerate() { + acc = acc.wrapping_add(b.wrapping_mul((i as u8).wrapping_add(1))); + } + if acc == 0 { + 0x20 + } else { + acc + } +} diff --git a/src/projection/mod.rs b/src/projection/mod.rs index 98148517..46b8d98b 100644 --- a/src/projection/mod.rs +++ b/src/projection/mod.rs @@ -19,9 +19,14 @@ mod provenance; // contract before their owning tasks define one. pub mod catalog; pub mod executor; +pub mod local_mounts; pub mod lower; pub mod placement; +pub use local_mounts::{ + LocalDirectMount, LocalEventualMount, LocalProjectionMounts, LocalProjectionMountsBuilder, +}; + pub use error::ProjectionProgramError; pub use expression::{ ProjectionAssignment, ProjectionEnvelopeField, ProjectionExpression, diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index e06041d0..0f4621d3 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -21,7 +21,7 @@ use crate::repository::RepositoryError; use crate::sqlx_repo::read_model::quote_identifier; use crate::sqlx_repo::repo::{ embedded_migrator, outbox_message_by_id, system_time_epoch_secs, SqlxOutboxStore, - SqlxRepository, + SqlxRepository, SQLITE_MIGRATIONS, }; use crate::sqlx_repo::{ self, is_sqlite_unique_constraint, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, @@ -33,30 +33,7 @@ use crate::table::{ ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError, }; -static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| { - embedded_migrator(&[ - ( - 1, - "initial", - include_str!("../../migrations/sqlite/0001_initial.sql"), - ), - ( - 2, - "command ledger", - include_str!("../../migrations/sqlite/0002_command_ledger.sql"), - ), - ( - 3, - "projection protocol", - include_str!("../../migrations/sqlite/0003_projection_protocol.sql"), - ), - ( - 4, - "command ledger atomic state", - include_str!("../../migrations/sqlite/0004_command_ledger_atomic_state.sql"), - ), - ]) -}); +static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| embedded_migrator(SQLITE_MIGRATIONS)); const SQLITE_BACKEND: &str = "sqlite"; const SIGNED_INTEGER_STORAGE: &str = "signed integer storage"; diff --git a/src/sqlx_repo/repo/backend.rs b/src/sqlx_repo/repo/backend.rs index d816447e..53535cf8 100644 --- a/src/sqlx_repo/repo/backend.rs +++ b/src/sqlx_repo/repo/backend.rs @@ -1,20 +1,26 @@ use super::*; -/// Build an embedded migrator from statically included migration files -/// (`(version, description, sql)` per file, in order). sqlx's `migrate!` -/// macro would assemble this at compile time but drags in the whole -/// proc-macro stack; here the checksums are computed once at first use, so -/// keep each backend's list in sync with its `migrations/` directory. -pub(crate) fn embedded_migrator(files: &[(i64, &'static str, &'static str)]) -> Migrator { +/// One migration registration emitted by the root build script. +#[derive(Clone, Copy)] +pub(crate) struct EmbeddedMigration { + pub(crate) version: i64, + pub(crate) description: &'static str, + pub(crate) sql: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/migration_inventory.rs")); + +/// Build an embedded migrator from the validated, generated migration inventory. +pub(crate) fn embedded_migrator(files: &[EmbeddedMigration]) -> Migrator { Migrator::with_migrations( files .iter() - .map(|&(version, description, sql)| { + .map(|migration| { Migration::new( - version, - description.into(), + migration.version, + migration.description.into(), MigrationType::Simple, - sqlx::SqlSafeStr::into_sql_str(sql), + sqlx::SqlSafeStr::into_sql_str(migration.sql), false, ) }) @@ -22,6 +28,111 @@ pub(crate) fn embedded_migrator(files: &[(i64, &'static str, &'static str)]) -> ) } +#[expect( + clippy::items_after_test_module, + reason = "migration parity tests stay beside generated registration data" +)] +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "sqlite")] + #[test] + fn generated_sqlite_inventory_preserves_order_descriptions_and_bytes() { + let versions = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.version) + .collect::>(); + let descriptions = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.description) + .collect::>(); + let sql = SQLITE_MIGRATIONS + .iter() + .map(|migration| migration.sql) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + descriptions, + vec![ + "initial", + "command ledger", + "projection protocol", + "command ledger atomic state" + ] + ); + assert_eq!( + sql, + vec![ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0001_initial.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0002_command_ledger.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0003_projection_protocol.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/sqlite/0004_command_ledger_atomic_state.sql" + )), + ] + ); + } + + #[cfg(feature = "postgres")] + #[test] + fn generated_postgres_inventory_preserves_order_descriptions_and_bytes() { + let versions = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.version) + .collect::>(); + let descriptions = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.description) + .collect::>(); + let sql = POSTGRES_MIGRATIONS + .iter() + .map(|migration| migration.sql) + .collect::>(); + assert_eq!(versions, vec![1, 2, 3, 4]); + assert_eq!( + descriptions, + vec![ + "initial", + "command ledger", + "projection protocol", + "command ledger atomic state" + ] + ); + assert_eq!( + sql, + vec![ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0001_initial.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0002_command_ledger.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0003_projection_protocol.sql" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/postgres/0004_command_ledger_atomic_state.sql" + )), + ] + ); + } +} + /// Group stream identities by aggregate type so each type is one id-list /// round trip instead of a query per identity. Callers issue single-type /// batches in the common case, so this usually yields one group; the grouping diff --git a/src/sqlx_repo/repo/mod.rs b/src/sqlx_repo/repo/mod.rs index 3805d093..716b1e16 100644 --- a/src/sqlx_repo/repo/mod.rs +++ b/src/sqlx_repo/repo/mod.rs @@ -90,6 +90,10 @@ use snapshots::*; pub(crate) use backend::embedded_migrator; pub use backend::SqlxRepoBackend; +#[cfg(feature = "postgres")] +pub(crate) use backend::POSTGRES_MIGRATIONS; +#[cfg(feature = "sqlite")] +pub(crate) use backend::SQLITE_MIGRATIONS; pub(crate) use errors::{repository_storage_error, system_time_epoch_secs}; #[cfg(feature = "sqlite")] pub(crate) use outbox::outbox_message_by_id; diff --git a/src/table/catalog.rs b/src/table/catalog.rs new file mode 100644 index 00000000..f8b6ec30 --- /dev/null +++ b/src/table/catalog.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +use super::{ + generate_table_migration_artifacts, table_schema_statements, TableMigrationArtifact, + TableSchema, TableSchemaRegistry, TableSqlDialect, TableStoreError, +}; + +/// A schema-only catalog for relational read models and operational tables. +/// +/// This is deliberately not an application manifest. It owns physical table +/// metadata and SQL rendering only; application commands, projections, +/// surfaces, provenance, and executable mounts belong to +/// [`crate::application::ApplicationManifest`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadModelCatalog { + pub name: String, + pub tables: Vec, +} + +impl ReadModelCatalog { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + tables: Vec::new(), + } + } + + pub fn read_model(mut self) -> Self + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_read_model::() + .expect("read model schema should be valid in the schema catalog"); + self + } + + pub fn try_read_model(mut self) -> Result + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_read_model::()?; + Ok(self) + } + + pub fn try_register_read_model(&mut self) -> Result<&mut Self, TableStoreError> + where + M: crate::read_model::RelationalReadModel, + { + self.try_register_table_schema(M::schema().clone()) + } + + pub fn table_schema(mut self, schema: TableSchema) -> Self { + self.try_register_table_schema(schema) + .expect("table schema should be valid in the schema catalog"); + self + } + + pub fn try_table_schema(mut self, schema: TableSchema) -> Result { + self.try_register_table_schema(schema)?; + Ok(self) + } + + pub fn try_register_table_schema( + &mut self, + schema: TableSchema, + ) -> Result<&mut Self, TableStoreError> { + let mut registry = self.table_registry()?; + registry.register_schema(schema.clone())?; + self.tables.push(schema); + self.tables.sort_by(|left, right| { + (left.model_name.as_str(), left.table_name.as_str()) + .cmp(&(right.model_name.as_str(), right.table_name.as_str())) + }); + Ok(self) + } + + pub fn table_registry(&self) -> Result { + let mut registry = TableSchemaRegistry::new(); + for schema in &self.tables { + registry.register_schema(schema.clone())?; + } + Ok(registry) + } + + pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, TableStoreError> { + table_schema_statements(&self.table_registry()?, dialect) + } + + pub fn sql_migration_artifacts( + &self, + dialect: TableSqlDialect, + ) -> Result, TableStoreError> { + generate_table_migration_artifacts(&self.table_registry()?, dialect) + } +} diff --git a/src/table/metadata.rs b/src/table/metadata.rs index 9009bb86..693d580e 100644 --- a/src/table/metadata.rs +++ b/src/table/metadata.rs @@ -168,7 +168,7 @@ pub struct RelationshipDef { /// Discriminator for tables owned by the framework vs read-model projections. /// /// Operational tables (outbox, inbox, …) are never exposed on the GraphQL query -/// surface; `from_manifest` / `graphql_sdl` consume only `ReadModel` entries. +/// surface; schema-catalog adapters consume only `ReadModel` entries. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum TableKind { #[default] diff --git a/src/table/mod.rs b/src/table/mod.rs index e216a873..8b05c0d1 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -7,6 +7,7 @@ //! typed staging/load surface on top of them. mod error; +mod catalog; mod metadata; mod mutation; mod plan; @@ -14,6 +15,7 @@ mod registry; mod sql; pub use error::TableStoreError; +pub use catalog::ReadModelCatalog; pub use metadata::{ ColumnType, ForeignKey, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, TableColumn, TableIndex, TableKind, TableSchema, DEFAULT_TABLE_VERSION_COLUMN, diff --git a/tests/application_composition.rs b/tests/application_composition.rs new file mode 100644 index 00000000..9aac133c --- /dev/null +++ b/tests/application_composition.rs @@ -0,0 +1,530 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use distributed::application::{ + Application, ApplicationExtension, CommandDefinition, CommandMount, CommandSpec, + CommandTypeField, CommandTypeSpec, ContractCompiler, Module, ProjectionSpec, SurfaceSpec, +}; +use distributed::graphql::{ + build_surface, col, surface_for_application_contract, surface_for_role, typed_command, + ClientSurfaceIdentity, CommandConsistency, RoleGrant, Succeeded, Surface, SurfaceOptions, +}; +use distributed::{ApplicationManifest, GraphqlInput, GraphqlOutput, ReadModel, RelationalReadModel}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, ReadModel, Serialize)] +#[table("todos")] +struct TodoView { + #[id("todo_id")] + todo_id: String, + title: String, + status: String, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize, GraphqlInput)] +struct ContractCommandInput { + title: String, +} + +#[derive(Clone, Serialize, GraphqlOutput)] +struct ContractCommandOutput { + id: String, +} + +fn typed_definition(id: &'static str, roles: &[&str]) -> CommandDefinition { + let command = typed_command::< + ContractCommandInput, + Succeeded, + >(id) + .roles(roles.iter().copied()); + CommandDefinition::from_typed_command(command, None) + .expect("typed contract should compile without an executable mount") +} + +fn command_module() -> Module { + Module::new("todo-contract") + .command_definitions([ + typed_definition("todo.allowed", &["user"]), + typed_definition("todo.forbidden", &["admin"]), + ]) + .build() + .expect("typed command module should compile") +} + +fn command_catalog() -> Surface { + full_surface() + .with_module(&command_module()) + .expect("module contracts should bind before authorization") +} + +fn grants_for(role: &str) -> BTreeMap { + BTreeMap::from([( + "TodoView".into(), + RoleGrant::all_columns().rows(col("status").eq(role)), + )]) +} + +fn command_names(surface: &Surface) -> Vec { + surface + .commands() + .iter() + .map(|command| command.command_name.clone()) + .collect() +} + +fn command(id: &str) -> CommandSpec { + CommandSpec::try_new( + id, + id.replace('.', "_"), + CommandTypeSpec { + name: format!("{id}Input"), + fields: vec![CommandTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: format!("{id}Output"), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandConsistency::Eventual, + ) + .expect("test command should be portable") +} + +fn definition(id: &str) -> CommandDefinition { + CommandDefinition::contract(command(id)) +} + +fn application_with_commands(order: &[&str]) -> Application { + let definitions = order.iter().map(|id| definition(id)).collect::>(); + let module = Module::new("todo") + .command_definitions(definitions) + .build() + .expect("test module should be valid"); + Application::new("todo-app") + .module(module) + .build() + .expect("test application should be valid") +} + +fn full_surface() -> Surface { + build_surface(&[TodoView::schema().clone()], &SurfaceOptions::sqlite()) + .expect("non-empty Surface should compile") +} + +fn selected_surface() -> Surface { + let full = full_surface(); + let grants = BTreeMap::from([( + "user".to_string(), + BTreeMap::from([( + "TodoView".to_string(), + RoleGrant::all_columns() + .with_aggregations() + .rows(col("status").eq("open")), + )]), + )]); + surface_for_application_contract( + &full, + "web", + &["user".into()], + &["user".into()], + &grants, + ) + .expect("role/application-selected Surface should compile") +} + +#[test] +fn explicit_module_commands_filter_allowed_and_forbidden_role_application_inventory() { + let catalog = command_catalog(); + let user_grants = grants_for("user"); + let role = surface_for_role(&catalog, "user", &user_grants).unwrap(); + assert_eq!(command_names(&role), vec!["todo.allowed"]); + + let application = surface_for_application_contract( + &catalog, + "web", + &["admin".into(), "user".into()], + &["user".into()], + &BTreeMap::from([("user".into(), user_grants)]), + ) + .unwrap(); + assert_eq!(command_names(&application), vec!["todo.allowed"]); + let spec = SurfaceSpec::from_surface("web", &application).unwrap(); + assert_eq!(spec.eligible_roles, ["admin", "user"]); + assert_eq!(spec.schema_roles, ["user"]); + assert_eq!(spec.commands.len(), 1); + assert_eq!(spec.commands[0].id, "todo.allowed"); + assert_eq!( + spec.contract["selection"], + serde_json::json!({ + "kind": "application", + "name": "web", + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"], + }) + ); + let client = ContractCompiler::from_surface("command-client", "web", Arc::new(application)) + .unwrap() + .client_manifest() + .unwrap(); + match client.surface { + ClientSurfaceIdentity::Application { + eligible_roles, + schema_roles, + .. + } => { + assert_eq!(eligible_roles, ["admin", "user"]); + assert_eq!(schema_roles, ["user"]); + } + other => panic!("expected application client identity, got {other:?}"), + } +} + +#[test] +fn role_and_application_command_closure_rejects_missing_unauthorized_and_tampered_inventories() { + let module = command_module(); + let catalog = command_catalog(); + let user_grants = grants_for("user"); + let selected = surface_for_application_contract( + &catalog, + "web", + &["admin".into(), "user".into()], + &["user".into()], + &BTreeMap::from([("user".into(), user_grants.clone())]), + ) + .unwrap(); + let valid = SurfaceSpec::from_surface("web", &selected).unwrap(); + Application::try_new("valid-command-closure", [module.clone()], [valid.clone()]) + .expect("authorized command closure should compile"); + + let mut missing = valid.clone(); + missing.commands.clear(); + missing.contract["commands"] = serde_json::json!([]); + missing.fingerprint = distributed::application::sha256_fingerprint( + &missing.canonical_bytes().unwrap(), + ); + let error = Application::try_new("missing-command", [module.clone()], [missing]) + .expect_err("missing selected command must fail closed"); + assert!(error.to_string().contains("exact authorized command closure")); + + let mut tampered = valid.clone(); + tampered.commands[0].roles = vec!["admin".into()]; + tampered.contract["commands"][0]["roles"] = serde_json::json!(["admin"]); + tampered.fingerprint = distributed::application::sha256_fingerprint( + &tampered.canonical_bytes().unwrap(), + ); + let error = Application::try_new("tampered-command", [module.clone()], [tampered]) + .expect_err("tampered selected command must fail closed"); + assert!(error.to_string().contains("not compatible")); + + let admin = surface_for_application_contract( + &catalog, + "admin-web", + &["admin".into()], + &["admin".into()], + &BTreeMap::from([("admin".into(), grants_for("admin"))]), + ) + .unwrap(); + let admin_spec = SurfaceSpec::from_surface("web", &admin).unwrap(); + let forbidden_command = admin_spec.commands[0].clone(); + let forbidden_contract = admin_spec.contract["commands"][0].clone(); + let mut unauthorized = valid; + unauthorized.commands.push(forbidden_command.clone()); + unauthorized.commands.sort_by(|left, right| left.id.cmp(&right.id)); + unauthorized + .contract["commands"] + .as_array_mut() + .expect("surface command contract array") + .push(forbidden_contract.clone()); + unauthorized.fingerprint = distributed::application::sha256_fingerprint( + &unauthorized.canonical_bytes().unwrap(), + ); + let error = Application::try_new("unauthorized-command", [module], [unauthorized]) + .expect_err("unauthorized selected command must fail closed"); + assert!(error.to_string().contains("exact authorized command closure")); + + let role_surface = surface_for_role(&catalog, "user", &user_grants).unwrap(); + let role_valid = SurfaceSpec::from_surface("user", &role_surface).unwrap(); + + let mut role_unauthorized = role_valid.clone(); + role_unauthorized.commands.push(forbidden_command); + role_unauthorized + .commands + .sort_by(|left, right| left.id.cmp(&right.id)); + role_unauthorized + .contract["commands"] + .as_array_mut() + .expect("surface command contract array") + .push(forbidden_contract); + role_unauthorized.fingerprint = distributed::application::sha256_fingerprint( + &role_unauthorized.canonical_bytes().unwrap(), + ); + let error = Application::try_new( + "unauthorized-role-command", + [command_module()], + [role_unauthorized], + ) + .expect_err("unauthorized role command must fail closed"); + assert!(error.to_string().contains("exact authorized command closure")); + + let mut role_tampered = role_valid.clone(); + role_tampered.commands[0].roles = vec!["admin".into()]; + role_tampered.contract["commands"][0]["roles"] = serde_json::json!(["admin"]); + role_tampered.fingerprint = distributed::application::sha256_fingerprint( + &role_tampered.canonical_bytes().unwrap(), + ); + let error = Application::try_new("tampered-role-command", [command_module()], [role_tampered]) + .expect_err("tampered role command must fail closed"); + assert!(error.to_string().contains("not compatible")); + + let mut role_missing = role_valid; + role_missing.commands.clear(); + role_missing.contract["commands"] = serde_json::json!([]); + role_missing.fingerprint = distributed::application::sha256_fingerprint( + &role_missing.canonical_bytes().unwrap(), + ); + let error = Application::try_new("missing-role-command", [command_module()], [role_missing]) + .expect_err("role command closure must fail closed"); + assert!(error.to_string().contains("exact authorized command closure")); +} + +#[test] +fn module_identity_is_identical_across_full_and_split_selection() { + let full = application_with_commands(&["todo.create", "todo.archive"]); + let split = application_with_commands(&["todo.archive", "todo.create"]); + + assert_eq!(full.manifest().commands, split.manifest().commands); + assert_eq!( + full.manifest().canonical_bytes().unwrap(), + split.manifest().canonical_bytes().unwrap() + ); +} + +#[test] +fn application_manifest_is_byte_deterministic_and_contains_no_executable_data() { + let spec = command("todo.create"); + let mount = CommandMount::from_request_handler(spec.clone(), |request| async move { + Ok(distributed::microsvc::CommandResponse { + status: 200, + body: request.input, + }) + }); + let definition = CommandDefinition::with_mount(spec, mount).unwrap(); + let module = Module::new("todo") + .command_definition(definition) + .build() + .expect("mounted module should be valid"); + let application = Application::new("todo-app") + .module(module) + .extension( + ApplicationExtension::try_new( + "ui", + 1, + serde_json::json!({ + "default_view": "board", + "columns": ["title", "status"], + "literal_url": "https://domain.example/view", + "literal_path": "orders/today" + }), + ) + .unwrap(), + ) + .build() + .unwrap(); + + let first = application.manifest().canonical_bytes().unwrap(); + let second = application.manifest().canonical_bytes().unwrap(); + assert_eq!(first, second); + let value: serde_json::Value = serde_json::from_slice(&first).unwrap(); + assert!(value.get("schema_version").is_some()); + assert!(value["fingerprints"]["canonical"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!( + value["extensions"][0]["value"]["literal_url"], + "https://domain.example/view" + ); + assert_eq!( + value["extensions"][0]["value"]["literal_path"], + "orders/today" + ); + assert!(!String::from_utf8(first.clone()).unwrap().contains("handler")); + assert!(!String::from_utf8(first.clone()) + .unwrap() + .contains("application_composition")); + assert!(ApplicationManifest::from_canonical_bytes(&first).is_ok()); + + let mut missing_version = value.clone(); + missing_version.as_object_mut().unwrap().remove("schema_version"); + let missing_version = serde_json::to_vec(&missing_version).unwrap(); + assert!(ApplicationManifest::from_canonical_bytes(&missing_version).is_err()); + + for field in ["tables", "services", "endpoints", "transport", "observability"] { + let mut legacy_owner = value.clone(); + legacy_owner[field] = serde_json::json!([]); + let legacy_owner = serde_json::to_vec(&legacy_owner).unwrap(); + assert!( + ApplicationManifest::from_canonical_bytes(&legacy_owner).is_err(), + "legacy logical-manifest field `{field}` must not decode" + ); + } +} + +#[test] +fn manifest_provenance_separates_logical_and_artifact_identity() { + let application = application_with_commands(&["todo.create"]); + let first = application + .manifest() + .clone() + .with_source_revision("git:one"); + let second = application + .manifest() + .clone() + .with_source_revision("git:two"); + + assert_eq!( + first.logical_fingerprint().unwrap(), + second.logical_fingerprint().unwrap() + ); + assert_ne!(first.fingerprint().unwrap(), second.fingerprint().unwrap()); + let decoded = + ApplicationManifest::from_canonical_bytes(&first.canonical_bytes().unwrap()).unwrap(); + assert_eq!(decoded.provenance.source_revision.as_deref(), Some("git:one")); + assert!( + String::from_utf8(first.canonical_bytes().unwrap()) + .unwrap() + .contains("git:one") + ); +} + +#[test] +fn contract_compiler_pins_manifest_sdl_and_client_to_one_surface() { + let selected = selected_surface(); + let compiler = ContractCompiler::from_surface( + "contract-only", + "web", + Arc::new(selected.clone()), + ) + .unwrap(); + let manifest = compiler.manifest().unwrap(); + let sdl = compiler.graphql_sdl().unwrap(); + let client = compiler.client_manifest().unwrap(); + + assert!(sdl.contains("TodoView")); + assert_eq!(manifest.surfaces.len(), 1); + assert_eq!(manifest.surfaces[0].selection, "application:web"); + assert!(client.models.iter().any(|model| model.typename == "TodoView")); + assert!(ContractCompiler::new("contract-only") + .with_surface("web", Arc::new(selected)) + .unwrap() + .with_surface("other", Arc::new(full_surface())) + .is_err()); + assert!(matches!( + client.surface, + ClientSurfaceIdentity::Application { ref name, .. } if name == "web" + )); +} + +#[test] +fn surface_contract_retains_policy_literals_and_rejects_stale_redundancy() { + let selected = selected_surface(); + let spec = distributed::application::SurfaceSpec::from_surface("web", &selected).unwrap(); + let row_policy = &spec.models[0].row_policy; + assert_eq!(row_policy["kind"], "predicate"); + assert!(row_policy.get("expression").is_some()); + assert_ne!(row_policy, &serde_json::json!("predicate")); + + let mut stale = spec.clone(); + stale.contract["models"][0]["table_name"] = serde_json::json!("other_table"); + stale.fingerprint = + distributed::application::sha256_fingerprint(&stale.canonical_bytes().unwrap()); + let error = Application::try_new("stale", [], [stale]).unwrap_err(); + assert!(error.to_string().contains("surface contract material")); +} + +#[test] +fn explicit_definition_mount_identity_and_missing_pairing_fail_closed() { + let spec = command("todo.create"); + let other = command("todo.other"); + let mount = CommandMount::contract(other); + let error = CommandDefinition::with_mount(spec, mount).unwrap_err(); + assert!(error.to_string().contains("definition and executable mount")); + + let duplicate = Module::new("todo") + .command_definitions([definition("todo.create"), definition("todo.create")]) + .build() + .expect_err("duplicate definitions must fail closed"); + assert!(duplicate.to_string().contains("duplicate command identity")); +} + +#[test] +fn no_linker_inventory_is_needed_for_explicit_application_selection() { + let application = application_with_commands(&["todo.create"]); + assert_eq!(application.manifest().module_ids(), ["todo"]); +} + +#[test] +fn nested_fingerprints_and_projection_references_are_fail_closed() { + let application = application_with_commands(&["todo.create"]); + let bytes = application.manifest().canonical_bytes().unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + value["commands"][0]["fingerprint"] = serde_json::json!(""); + let malformed: ApplicationManifest = serde_json::from_value(value) + .expect("the malformed value should still be structurally deserializable"); + assert!(malformed.clone().refresh_fingerprints().is_err()); + assert!(ApplicationManifest::from_canonical_bytes( + &serde_json::to_vec(&serde_json::to_value(malformed).unwrap()).unwrap() + ) + .is_err()); + + let mut projection = ProjectionSpec::try_new( + "todo.list", + std::iter::empty::(), + ["TodoView"], + ) + .unwrap(); + projection.dependencies.push("projection:missing".into()); + projection.dependencies.sort(); + projection.fingerprint = distributed::application::sha256_fingerprint( + &projection.canonical_bytes().unwrap(), + ); + let module = Module::new("todo") + .surface( + distributed::application::SurfaceSpec::from_surface("web", &selected_surface()) + .unwrap(), + ) + .projection(projection) + .build() + .unwrap(); + let error = Application::try_new("projection-owner", [module], []) + .expect_err("missing projection dependencies must fail closed"); + assert!(error.to_string().contains("missing")); +} + +#[test] +fn application_surface_must_expose_a_schema_role() { + let mut surface = + distributed::application::SurfaceSpec::from_surface("web", &selected_surface()).unwrap(); + surface.schema_roles.clear(); + surface.contract["selection"]["schema_roles"] = serde_json::json!([]); + surface.fingerprint = distributed::application::sha256_fingerprint( + &surface.canonical_bytes().unwrap(), + ); + let error = Application::try_new("role-owner", [], [surface]) + .expect_err("application surfaces without schema roles must fail closed"); + assert!(error.to_string().contains("schema role")); +} diff --git a/tests/application_plans.rs b/tests/application_plans.rs new file mode 100644 index 00000000..258f92bc --- /dev/null +++ b/tests/application_plans.rs @@ -0,0 +1,295 @@ +//! Deployment plan compiler coverage for task 10. + +use distributed::application::{ + compile_deployment_plan, Application, CommandDefinition, CommandSpec, CommandTypeField, + CommandTypeSpec, DeploymentPlan, ModelFieldSpec, ModelSpec, Module, MountSelector, + ProcessIntent, ProcessPreset, ProjectionSpec, +}; +use distributed::graphql::CommandConsistency; + +fn portable_command(id: &str, consistency: CommandConsistency) -> CommandSpec { + let command = CommandSpec::try_new( + id, + id.replace('.', "_"), + CommandTypeSpec { + name: format!("{id}Input"), + fields: vec![CommandTypeField { + name: "title".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + CommandTypeSpec { + name: format!("{id}Output"), + fields: vec![CommandTypeField { + name: "id".into(), + type_name: "String".into(), + nullable: false, + list: false, + item_nullable: false, + nested: None, + }], + }, + consistency, + ) + .expect("portable command"); + if matches!(consistency, CommandConsistency::Atomic) { + command + .with_direct_projection( + "TodoView", + serde_json::json!({"model": "TodoView", "kind": "direct"}), + ) + .expect("atomic proof") + } else { + command + } +} + +fn definition(id: &str, consistency: CommandConsistency) -> CommandDefinition { + CommandDefinition::contract(portable_command(id, consistency)) +} + +fn todo_model() -> ModelSpec { + ModelSpec::try_new( + "TodoView", + "todos", + [ + ModelFieldSpec { + name: "todo_id".into(), + scalar: "String".into(), + nullable: false, + }, + ModelFieldSpec { + name: "title".into(), + scalar: "String".into(), + nullable: false, + }, + ], + ["todo_id"], + ) + .expect("todo model") +} + +fn plan_manifest() -> distributed::ApplicationManifest { + let module = Module::new("todo") + .command_definitions([ + definition("todo.create", CommandConsistency::Eventual), + definition("todo.force", CommandConsistency::Atomic), + ]) + .models([todo_model()]) + .projections([ + ProjectionSpec::try_new("project_todos", ["todo.created"], ["TodoView"]).unwrap(), + ProjectionSpec::try_new("project_todos_direct", ["todo.forced"], ["TodoView"]) + .unwrap() + .with_direct(true) + .unwrap(), + ]) + .build() + .expect("module should build"); + Application::new("todo-app") + .module(module) + .build() + .expect("application should build") + .manifest() + .clone() +} + +#[test] +fn full_preset_lowers_to_ordinary_mounts_and_is_byte_deterministic() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "local-full", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .expect("full plan"); + assert_eq!(plan.processes.len(), 1); + let mounts = &plan.processes[0].mounts; + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Command { id } if id == "todo.create"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Command { id } if id == "todo.force"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Projector { id } if id == "project_todos"))); + assert!(mounts + .iter() + .any(|m| matches!(m, MountSelector::Projector { id } if id == "project_todos_direct"))); + + let first = plan.encode().unwrap(); + let second = compile_deployment_plan( + "local-full", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap() + .encode() + .unwrap(); + assert_eq!(first, second); + assert_eq!( + DeploymentPlan::from_canonical_bytes(&first) + .unwrap() + .application_manifest_logical, + manifest.fingerprints.logical + ); +} + +#[test] +fn presets_and_mixed_selection_share_one_mount_algebra() { + let manifest = plan_manifest(); + let writer = ProcessPreset::Writer.expand(&manifest).unwrap(); + let projector = ProcessPreset::Projector.expand(&manifest).unwrap(); + assert!(writer + .iter() + .all(|m| matches!(m, MountSelector::Command { .. }))); + assert!(projector + .iter() + .all(|m| matches!(m, MountSelector::Projector { .. }))); + + let mixed = compile_deployment_plan( + "mixed", + &manifest, + [ProcessIntent::new("api") + .unwrap() + .mounts([ + MountSelector::command("todo.create").unwrap(), + MountSelector::projector("project_todos").unwrap(), + ]) + .remote_commands(false)], + ) + .expect("mixed plan"); + assert_eq!(mixed.processes[0].mounts.len(), 2); + assert!(mixed + .capabilities + .iter() + .any(|cap| cap.capability.as_str() == "event_store")); +} + +#[test] +fn atomic_separation_fails_and_eventual_split_succeeds() { + let manifest = plan_manifest(); + + let eventual = compile_deployment_plan( + "eventual-split", + &manifest, + [ + ProcessIntent::new("writer") + .unwrap() + .mounts([MountSelector::command("todo.create").unwrap()]), + ProcessIntent::new("projectors") + .unwrap() + .mounts([MountSelector::projector("project_todos").unwrap()]), + ], + ); + assert!(eventual.is_ok(), "{eventual:?}"); + + let atomic_split = compile_deployment_plan( + "atomic-split", + &manifest, + [ + ProcessIntent::new("writer") + .unwrap() + .mounts([MountSelector::command("todo.force").unwrap()]), + ProcessIntent::new("projectors") + .unwrap() + .mounts([MountSelector::projector("project_todos_direct").unwrap()]), + ], + ); + assert!( + atomic_split.is_err(), + "atomic command must not separate from direct projection" + ); + let message = atomic_split.unwrap_err().to_string(); + assert!( + message.contains("todo.force") && message.to_lowercase().contains("collocat"), + "{message}" + ); + + let collocated = compile_deployment_plan( + "atomic-local", + &manifest, + [ProcessIntent::new("writer") + .unwrap() + .mounts([ + MountSelector::command("todo.force").unwrap(), + MountSelector::projector("project_todos_direct").unwrap(), + ])], + ); + assert!(collocated.is_ok(), "{collocated:?}"); +} + +#[test] +fn default_empty_process_list_expands_to_full_local() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan("default", &manifest, []).expect("default full"); + assert_eq!(plan.processes.len(), 1); + assert_eq!(plan.processes[0].id, "full"); + assert!(!plan.processes[0].remote_commands); +} + +#[test] +fn unknown_mount_and_duplicate_process_fail_closed() { + let manifest = plan_manifest(); + let missing = compile_deployment_plan( + "missing", + &manifest, + [ProcessIntent::new("p") + .unwrap() + .mounts([MountSelector::command("todo.missing").unwrap()])], + ); + assert!(missing.is_err()); + + let duplicate = compile_deployment_plan( + "dup", + &manifest, + [ + ProcessIntent::with_preset("same", &manifest, ProcessPreset::Writer).unwrap(), + ProcessIntent::with_preset("same", &manifest, ProcessPreset::Projector).unwrap(), + ], + ); + assert!(duplicate.is_err()); +} + +#[test] +fn capability_closure_is_explained_and_schema_has_one_owner() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "caps", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap(); + assert!(plan.schema_lifecycle.required); + assert_eq!( + plan.schema_lifecycle.logical_owner.as_deref(), + Some("todo-app") + ); + assert!(plan.capabilities.iter().any(|cap| !cap.reasons.is_empty())); + let described = plan.describe(); + assert_eq!(described["application"], "todo-app"); + assert!(!described["capabilities"].as_array().unwrap().is_empty()); +} + +#[test] +fn stale_manifest_predecessor_is_retained_exactly() { + let manifest = plan_manifest(); + let plan = compile_deployment_plan( + "pred", + &manifest, + [ProcessIntent::with_preset("full", &manifest, ProcessPreset::Full).unwrap()], + ) + .unwrap(); + assert_eq!( + plan.application_manifest_logical, + manifest.fingerprints.logical + ); + assert_eq!( + plan.application_manifest_canonical, + manifest.fingerprints.canonical + ); +} diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 7595b851..4afb618c 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -7,7 +7,7 @@ .PHONY: all up down run run-api stop test ci-offline test-domain test-suite \ test-browser test-browser-install js-install js-build ui-install ui-build ui-check ui-test \ - gen-client check-client check clean help + gen-client check-client contracts-check check clean help # Defaults only — do NOT `include e2e-ui.env` (shell-quoted dotenv breaks Make). # Recipes `source` the env file so values stay clean. @@ -157,10 +157,18 @@ ui-test: ui-install gen-client: ui-install cd ui && $(NPM) run client:generate -## Byte/file-set drift check through dctl --check; never repairs generated files. +## Byte/file-set drift check through distributed --check; never repairs generated files. check-client: ui-install cd ui && $(NPM) run client:check +## Aggregate read-only contract check (repo root catalog when present). +contracts-check: + @if [ -f ../../contracts/catalog.json ] || [ -f contracts/catalog.json ]; then \ + $(MAKE) -C ../.. contracts-check; \ + else \ + echo "contracts-check: no catalog yet; skipping aggregate gate"; \ + fi + check: check-client cargo check --workspace cargo test -p todo-domain -p chat-domain --no-run diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index 308b2662..3d34c7d0 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -70,20 +70,19 @@ projection! { } ``` -Command registration declares the domain events this command may emit and the -**known mutation input** used for client cache application (not a separate -hand-built cache path): +Command registration binds a **domain transition** (emit set) and lets the +framework auto-derive client cache previews from input + defaults + claims +(not a separate hand-built mapping): ```rust -typed_command::>("todo.complete") - .emits(events![TodoCompletedDomainEvent]) - .applies(state_preview! { - TodoCompletedDomainEvent => TodoState { - todo_id: input.todo_id, - status: "completed", - ..unknown - } - }) +.command_transition::< + domain_commands::Complete, + TodoCompleteInput, + Eventual, +>("todo.complete") +.field_name("todos_complete") +.roles(["user", "admin"]) +.handle(todo_complete::handle) ``` The compiler specializes `TODOS` into safe client operations: apply the same @@ -143,8 +142,8 @@ differs on purpose — do **not** collapse them into “always send a causal del | Contract | Apply site | Mutation response (ship) | Client seal | |---|---|---|---| -| **`Eventual`** + Eventual | Event handler after commit | Payload + **projection-delta** + `expects` | `.applies` preview; retire on obligations | -| **`Atomic`** + Direct | Command handler, same tx | **Typed row `M`** + direct **`records[]`**. No eventual modeled metadata, empty `expects` | Optional `.applies`; **`confirmDirectProjection(row, records)`** before `await` settles | +| **`Eventual`** + Eventual | Event handler after commit | Payload + **projection-delta** + `expects` | Auto-optimism preview; retire on obligations | +| **`Atomic`** + Direct | Command handler, same tx | **Typed row `M`** + direct **`records[]`**. No eventual modeled metadata, empty `expects` | Auto-optimism when input known; **`confirmDirectProjection(row, records)`** before `await` settles | Handler for Atomic — this *is* returning atomic read-model updates: @@ -192,8 +191,9 @@ programs; the same IR lowers on the server and for role-safe client cache optimism. Multi-model atomicity is expressed as multi-op mutation programs, not a public projector ORM workspace. -Application commands declare `.emits` plus `.applies(...)` known mutation-input -mapping for client cache application. Both Eventual and Direct surfaces may +Application commands use `command_transition` so the emit set comes from the +domain (`domain_commands::*`); client cache previews are auto-derived from +input, defaults, and row-policy claims. Both Eventual and Direct surfaces may export those previews from the portable program. Eventual commands do not stage rows in the handler (the event handler applies the mutation later). Blob stages the mutation-derived row with `readmodel(row).commit()?.projected()` so the diff --git a/tests/e2e-ui/contracts/application-manifest.placeholder.json b/tests/e2e-ui/contracts/application-manifest.placeholder.json new file mode 100644 index 00000000..333e6c6b --- /dev/null +++ b/tests/e2e-ui/contracts/application-manifest.placeholder.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-ui", + "modules": [ + "todo", + "chat", + "blob", + "identity" + ], + "surfaces": [ + "e2e-ui", + "e2e-ui-admin", + "e2e-ui-public" + ], + "note": "Canonical ApplicationManifest bytes are produced by the application composition compiler; this inventory documents selectable process cuts for task 13." +} diff --git a/tests/e2e-ui/contracts/deployment-plans/api-only.json b/tests/e2e-ui/contracts/deployment-plans/api-only.json new file mode 100644 index 00000000..7aadc5d1 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/api-only.json @@ -0,0 +1,11 @@ +{ + "name": "e2e-ui-api", + "application": "e2e-ui", + "processes": [ + { + "id": "api", + "preset": "query_api", + "remote_commands": true + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/full.json b/tests/e2e-ui/contracts/deployment-plans/full.json new file mode 100644 index 00000000..cb3134d0 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/full.json @@ -0,0 +1,11 @@ +{ + "name": "e2e-ui-full", + "application": "e2e-ui", + "processes": [ + { + "id": "full", + "preset": "full", + "remote_commands": false + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/mixed.json b/tests/e2e-ui/contracts/deployment-plans/mixed.json new file mode 100644 index 00000000..325ac87a --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/mixed.json @@ -0,0 +1,19 @@ +{ + "name": "e2e-ui-mixed", + "application": "e2e-ui", + "processes": [ + { + "id": "mixed", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + }, + { + "kind": "surface", + "id": "e2e-ui" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json b/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json new file mode 100644 index 00000000..0f6b1e0e --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/projector-todo-chat.json @@ -0,0 +1,19 @@ +{ + "name": "e2e-ui-projectors", + "application": "e2e-ui", + "processes": [ + { + "id": "projectors", + "mounts": [ + { + "kind": "projector", + "id": "project_todos" + }, + { + "kind": "projector", + "id": "project_chat" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/split.json b/tests/e2e-ui/contracts/deployment-plans/split.json new file mode 100644 index 00000000..e6f6cdb1 --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/split.json @@ -0,0 +1,29 @@ +{ + "name": "e2e-ui-split", + "application": "e2e-ui", + "processes": [ + { + "id": "api", + "preset": "query_api", + "remote_commands": true + }, + { + "id": "todo-writer", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + } + ] + }, + { + "id": "projectors", + "mounts": [ + { + "kind": "projector", + "id": "project_todos" + } + ] + } + ] +} diff --git a/tests/e2e-ui/contracts/deployment-plans/writer-todo.json b/tests/e2e-ui/contracts/deployment-plans/writer-todo.json new file mode 100644 index 00000000..b7bd2bde --- /dev/null +++ b/tests/e2e-ui/contracts/deployment-plans/writer-todo.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-ui-writer-todo", + "application": "e2e-ui", + "processes": [ + { + "id": "todo-writer", + "mounts": [ + { + "kind": "command", + "id": "todo.create" + } + ] + } + ] +} diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index ed98c039..ecaa3728 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -9,7 +9,7 @@ pub mod models; pub use levels::{demo_map, generate_level, generate_level_with, is_hamiltonian_passable}; pub use models::tile; pub use models::{ - simulate_move, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, - BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, + domain_commands, simulate_move, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, + BlobGameState, BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, BlobStartedDomainEvent, Direction, MovePreview, }; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs index e0316c2c..a2f7ea52 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs @@ -8,7 +8,7 @@ pub mod tile; pub use blob_error::BlobError; pub use blob_game::{ - simulate_move, test_map_no_holes, test_map_with_hole, BlobGame, MovePreview, + domain_commands, simulate_move, test_map_no_holes, test_map_with_hole, BlobGame, MovePreview, BlobGameInitializedDomainEvent as BlobInitializedDomainEvent, BlobGameLevelStartedDomainEvent as BlobLevelStartedDomainEvent, BlobGameMovedDomainEvent as BlobMovedDomainEvent, diff --git a/tests/e2e-ui/crates/chat-domain/src/lib.rs b/tests/e2e-ui/crates/chat-domain/src/lib.rs index d89f4c8c..2c35356c 100644 --- a/tests/e2e-ui/crates/chat-domain/src/lib.rs +++ b/tests/e2e-ui/crates/chat-domain/src/lib.rs @@ -2,4 +2,6 @@ pub mod models; -pub use models::{ChatError, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState}; +pub use models::{ + domain_commands, ChatError, ChatMessage, ChatMessagePostedDomainEvent, ChatMessageState, +}; diff --git a/tests/e2e-ui/crates/chat-domain/src/models/mod.rs b/tests/e2e-ui/crates/chat-domain/src/models/mod.rs index 87931869..39f97a18 100644 --- a/tests/e2e-ui/crates/chat-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/chat-domain/src/models/mod.rs @@ -5,5 +5,5 @@ mod chat_message; mod chat_message_state; pub use chat_error::ChatError; -pub use chat_message::{ChatMessage, ChatMessagePostedDomainEvent}; +pub use chat_message::{domain_commands, ChatMessage, ChatMessagePostedDomainEvent}; pub use chat_message_state::ChatMessageState; diff --git a/tests/e2e-ui/crates/readmodels/src/lib.rs b/tests/e2e-ui/crates/readmodels/src/lib.rs index 8454992f..d406e81c 100644 --- a/tests/e2e-ui/crates/readmodels/src/lib.rs +++ b/tests/e2e-ui/crates/readmodels/src/lib.rs @@ -7,10 +7,12 @@ pub mod models; pub use models::{AuthUsers, BlobGames, ChatMessages, Todos}; -pub fn distributed_manifest() -> distributed::DistributedProjectManifest { +pub fn distributed_manifest() -> distributed::ReadModelCatalog { use distributed::RelationalReadModel; - distributed::DistributedProjectManifest::new("e2e-ui") + // Schema-only catalog (physical tables). ApplicationManifest is the logical + // application contract and is composed separately. + distributed::ReadModelCatalog::new("e2e-ui") .table_schema(Todos::schema().clone()) .table_schema(ChatMessages::schema().clone()) .table_schema(BlobGames::schema().clone()) diff --git a/tests/e2e-ui/crates/runner/src/main.rs b/tests/e2e-ui/crates/runner/src/main.rs index 50d2abb0..014a2df5 100644 --- a/tests/e2e-ui/crates/runner/src/main.rs +++ b/tests/e2e-ui/crates/runner/src/main.rs @@ -1,4 +1,4 @@ -//! e2e-ui runner — SQLite (offline) or Postgres (compose stack). +//! e2e-ui runner — one-screen host invocation. //! //! Env: //! - `DATABASE_URL` — `sqlite:…` (default) or `postgres://…` @@ -8,196 +8,20 @@ //! - `ZITADEL_SCRAPE_INTERVAL_SECS` (default 60; `0` = no background loop) use std::env; -use std::sync::Arc; -use std::time::Duration; -use distributed::bus::{PostgresBus, RunOptions, SqliteBus}; -use distributed::{ - BusPublisher, OutboxDispatcher, PostgresLockManager, PostgresRepository, SqliteLockManager, - SqliteRepository, -}; -use e2e_service::{ - build_graphql_engine, build_service, distributed_manifest, identity_from_env, serve_with_oidc, - spawn_scrape_loop, ZitadelScrapeConfig, -}; - -const BUS_GROUP: &str = "e2e-ui"; +use e2e_service::{identity_from_env, run_e2e_host, HostOptions}; #[tokio::main] async fn main() -> Result<(), Box> { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./e2e-ui.db?mode=rwc".into()); let bind = env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8791".into()); - let identity = identity_from_env(); - - if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { - run_postgres(&database_url, &bind, identity).await - } else { - run_sqlite(&database_url, &bind, identity).await - } -} - -async fn run_sqlite( - database_url: &str, - bind: &str, - identity: distributed::graphql::IdentityConfig, -) -> Result<(), Box> { - let repo = SqliteRepository::connect_and_migrate(database_url).await?; - let registry = distributed_manifest() - .table_registry() - .map_err(|e| format!("manifest: {e}"))?; - repo.bootstrap_table_schema_for_dev(®istry).await?; - let locks = SqliteLockManager::new(repo.pool().clone()); - - let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); - bus.ensure_tables().await?; - - let change_rx = repo.read_model_changes(); - let service = build_service(repo.clone(), locks.clone(), repo.clone()) - .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, identity.clone(), Some(change_rx))?; - let http_service = Arc::new(service.try_with_graphql(gql)?); - - spawn_outbox_sqlite(repo.clone()); - spawn_consumer_sqlite(repo.clone(), locks); - spawn_zitadel_scrape(repo.clone()); - - eprintln!("e2e-ui (sqlite) listening on http://{bind}"); - serve_with_oidc(http_service, identity, bind).await?; - Ok(()) -} - -async fn run_postgres( - database_url: &str, - bind: &str, - identity: distributed::graphql::IdentityConfig, -) -> Result<(), Box> { - let repo = PostgresRepository::connect_and_migrate(database_url).await?; - let registry = distributed_manifest() - .table_registry() - .map_err(|e| format!("manifest: {e}"))?; - repo.bootstrap_table_schema_for_dev(®istry).await?; - let locks = PostgresLockManager::new(repo.pool().clone()); - - let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); - bus.ensure_tables().await?; - - let change_rx = repo.read_model_changes(); - let service = build_service(repo.clone(), locks.clone(), repo.clone()) - .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); - let gql = build_graphql_engine(&repo, &service, identity.clone(), Some(change_rx))?; - let http_service = Arc::new(service.try_with_graphql(gql)?); - - spawn_outbox_postgres(repo.clone()); - spawn_consumer_postgres(repo.clone(), locks); - spawn_zitadel_scrape(repo.clone()); - - eprintln!("e2e-ui (postgres) listening on http://{bind}"); - serve_with_oidc(http_service, identity, bind).await?; - Ok(()) -} - -fn spawn_zitadel_scrape(repo: R) -where - R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, -{ - match ZitadelScrapeConfig::from_env() { - Some(cfg) if cfg.background_enabled() || cfg.on_start => { - eprintln!( - "zitadel scrape: enabled (api={}, interval={}s, on_start={})", - cfg.api_base, - cfg.interval.as_secs(), - cfg.on_start - ); - spawn_scrape_loop(repo, cfg); - } - Some(_) => { - eprintln!("zitadel scrape: credentials present, background off (interval=0); use POST /zitadel.scrape.v1"); - } - None => { - eprintln!( - "zitadel scrape: disabled (set ZITADEL_SERVICE_USER_TOKEN + OIDC_ISSUER/ZITADEL_API_URL)" - ); - } - } -} - -fn spawn_outbox_sqlite(repo: SqliteRepository) { - tokio::spawn(async move { - let bus = Arc::new(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); - let dispatcher = OutboxDispatcher::new( - repo.outbox_store(), - BusPublisher::new(bus), - format!("outbox:{}", std::process::id()), - Duration::from_secs(30), - 5, - ) - .with_service("e2e-ui"); - loop { - match dispatcher.dispatch_batch(32).await { - Ok(o) if o.published > 0 || o.claimed > 0 => {} - Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("outbox: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_consumer_sqlite(repo: SqliteRepository, locks: SqliteLockManager) { - tokio::spawn(async move { - loop { - let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); - let service = build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus); - match service.run(RunOptions::idempotent()).await { - Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("consumer: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_outbox_postgres(repo: PostgresRepository) { - tokio::spawn(async move { - let bus = Arc::new(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); - let dispatcher = OutboxDispatcher::new( - repo.outbox_store(), - BusPublisher::new(bus), - format!("outbox:{}", std::process::id()), - Duration::from_secs(30), - 5, - ) - .with_service("e2e-ui"); - loop { - match dispatcher.dispatch_batch(32).await { - Ok(o) if o.published > 0 || o.claimed > 0 => {} - Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("outbox: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); -} - -fn spawn_consumer_postgres(repo: PostgresRepository, locks: PostgresLockManager) { - tokio::spawn(async move { - loop { - let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); - let service = build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus); - match service.run(RunOptions::idempotent()).await { - Ok(()) => tokio::time::sleep(Duration::from_millis(25)).await, - Err(e) => { - eprintln!("consumer: {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); + run_e2e_host( + &database_url, + HostOptions { + bind, + identity: identity_from_env(), + }, + ) + .await } diff --git a/tests/e2e-ui/crates/service/src/application.rs b/tests/e2e-ui/crates/service/src/application.rs new file mode 100644 index 00000000..fd816495 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/application.rs @@ -0,0 +1,28 @@ +//! e2e-ui application composition root. +//! +//! This is the review-visible product declaration: surface identities, module +//! inventory, and re-exports of the composed host APIs. Infrastructure +//! (dialect, outbox, OIDC serve) stays in `host`; handlers stay in modules. + +use crate::modules::{blob, chat, compose, todo}; + +/// Stable normal-application surface shared by user and admin sessions. +pub const DISTRIBUTED_CLIENT_SURFACE: &str = "e2e-ui"; +/// Stable elevated surface for routes that intentionally include admin-only fields. +pub const DISTRIBUTED_ADMIN_CLIENT_SURFACE: &str = "e2e-ui-admin"; +/// Unauthenticated public surface (lobby message peek). +pub const DISTRIBUTED_PUBLIC_CLIENT_SURFACE: &str = "e2e-ui-public"; + +/// Logical application name used for manifest / plan identity. +pub const E2E_UI_APPLICATION: &str = "e2e-ui"; + +/// Explicit module identities owned by the e2e application. +pub const E2E_UI_MODULE_IDS: &[&str] = compose::MODULE_IDS; + +/// Compile-time proof that module inventory matches bounded-context crates. +pub const MODULE_DECLARATIONS: &[(&str, &str)] = &[ + (todo::MODULE_ID, "todo commands + projector"), + (chat::MODULE_ID, "chat commands + Zitadel extension + projectors"), + (blob::MODULE_ID, "blob Atomic commands"), + ("identity", "AuthUsers projection via chat module ingestors"), +]; diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs index 482910cf..081a0914 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs @@ -1,9 +1,8 @@ //! Command: `blob.move` — direction up|down|left|right. //! -//! Optimistic outcome fields on the input are **client preview fill** (same -//! pattern as chat `created_at` / `message_id`): the generated `.applies` -//! preview maps them into the replica optimistic layer. Authority still comes -//! only from `game_id` + `direction` + domain `move_dir`. +//! Input is only what the client knows (`game_id` + `direction`). Server domain +//! seals via Atomic. Client may run the declared pure (`blob.simulate_move` / +//! `$lib/blob/simulate-move`) for known-row optimism; that paint is provisional. use blob_domain::{BlobGame, BlobGameState, Direction}; use distributed::graphql::{Atomic, PreparedCommand}; @@ -12,7 +11,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.move"; @@ -20,21 +19,13 @@ pub const COMMAND: &str = "blob.move"; pub struct BlobMoveInput { pub game_id: String, pub direction: String, - /// Optimistic board JSON (`number[][]`) for `.applies` preview only. - pub map_json: String, - pub score: i64, - pub player_dead: bool, - pub current_level: i64, - pub current_level_completed: bool, - /// `active` | `dead` | `level_complete` - pub status: String, } pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobMoveInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let dir = Direction::parse(&input.direction).ok_or_else(|| { HandlerError::Rejected(format!( "invalid direction `{}` (use up|down|left|right)", @@ -47,10 +38,8 @@ pub async fn handle( .get(&input.game_id) .await? .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; - // Preview fields on `input` are not trusted for authority. game.move_dir(&owner, dir).map_err(rejected)?; - // Handler-owned atomic: same mutation IR as event→mutation bindings. let row = save_blob_game() .from_state(&BlobGameState::from(&*game)) .map_err(|error| HandlerError::Other(Box::new(error)))?; diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs index b92d8c56..3d89e9b7 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs @@ -7,7 +7,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.start"; @@ -22,7 +22,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); if repo.get(&input.game_id).await?.is_some() { diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs index c4fba25b..b83a7844 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs @@ -7,7 +7,7 @@ use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; use serde::Deserialize; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "blob.start_level"; @@ -20,7 +20,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartLevelInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut game = repo .get(&input.game_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs index 77088a5c..a5118dbd 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs @@ -5,7 +5,7 @@ use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "chat.post"; @@ -32,7 +32,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, ChatMessage>, input: ChatPostInput, ) -> Result>, HandlerError> { - let author = ctx.user_id()?.to_string(); + let author = principal(ctx)?; let created_at = canonical_near_unix_millis(&input.created_at)?; let repo = ctx.repo(); diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs index 4d199736..4ae904c2 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.archive"; @@ -22,7 +22,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoArchiveInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs index 760d4e11..9a10485b 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.complete"; @@ -19,7 +19,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoCompleteInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs index f7f8d035..60dbf185 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs @@ -1,14 +1,14 @@ //! Command: `todo.create` — owner is always the authenticated session user. //! -//! GraphQL: exposed as mutation field `todos_create` (roles: user, admin). -//! Owner cannot be spoofed via input — only `require_user(session)` is written. +//! GraphQL: `todos_create` (roles: user, admin). Session admission is the +//! mount guard (`causal_has_user`); this body binds that principal as owner. use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.create"; @@ -33,7 +33,7 @@ pub async fn handle( input: TodoCreateInput, ) -> Result>, HandlerError> { // Owner is always the authenticated principal — not client-supplied. - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); if repo.get(&input.todo_id).await?.is_some() { diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs index 1e37e134..98ee8d35 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs @@ -9,7 +9,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.force_archive"; @@ -31,7 +31,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoForceArchiveInput, ) -> Result>, HandlerError> { - let admin = ctx.user_id()?.to_string(); + let admin = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs index 44b20514..0d820c2e 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs @@ -5,7 +5,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::Todo; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.purge"; @@ -24,7 +24,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoPurgeInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs index 946f1d71..0a512b7b 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs @@ -5,7 +5,7 @@ use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.rename"; @@ -26,7 +26,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoRenameInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs index 03623fc1..9ef34d79 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use todo_domain::{Todo, TodoState}; use crate::handlers::commands::payloads::TodoStatusPayload; -use crate::handlers::util::rejected; +use crate::handlers::util::{principal, rejected}; pub const COMMAND: &str = "todo.reopen"; @@ -21,7 +21,7 @@ pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoReopenInput, ) -> Result>, HandlerError> { - let owner = ctx.user_id()?.to_string(); + let owner = principal(ctx)?; let repo = ctx.repo(); let mut todo = repo .get(&input.todo_id) diff --git a/tests/e2e-ui/crates/service/src/handlers/util.rs b/tests/e2e-ui/crates/service/src/handlers/util.rs index a51cebd3..9aa0154b 100644 --- a/tests/e2e-ui/crates/service/src/handlers/util.rs +++ b/tests/e2e-ui/crates/service/src/handlers/util.rs @@ -1,8 +1,15 @@ //! Shared handler helpers. +//! +//! **Admission vs domain** +//! - [`session_has_user`] / [`session_is_admin`] / [`causal_has_user`] / +//! [`causal_is_admin`] — command **guards** (session admission only). +//! - Handler bodies bind the principal and call the domain; they do not re-check +//! “am I logged in?” when a guard already did. +//! - Domain owns entity invariants (empty title, ownership, board rules). use distributed::bus::Message; -use distributed::microsvc::{HandlerError, Session}; -use distributed::{BitcodePayloadCodec, PayloadCodec}; +use distributed::microsvc::{CausalCommandContext, HandlerError, Session}; +use distributed::{Aggregate, BitcodePayloadCodec, PayloadCodec}; use serde::de::DeserializeOwned; /// Decode event payload as JSON (tests) or bitcode (outbox → bus). @@ -50,6 +57,30 @@ pub fn session_is_admin(session: &Session) -> bool { session.has_role("admin") } +/// Typed causal guard: non-empty session user id. +pub fn causal_has_user(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) +} + +/// Typed causal guard: session user present and carries `admin`. +pub fn causal_is_admin(ctx: &CausalCommandContext<'_, A>) -> bool +where + A: Aggregate + Send + Sync + 'static, +{ + session_has_user(ctx.session()) && session_is_admin(ctx.session()) +} + +/// Principal after a user-session guard (for domain `owner_id` / author args). +pub fn principal(ctx: &CausalCommandContext<'_, A>) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + ctx.user_id().map(str::to_string) +} + /// Require engine role `admin` (handler-path Result form). pub fn require_admin(session: &Session) -> Result<(), HandlerError> { if session.has_role("admin") { @@ -108,4 +139,13 @@ mod tests { s.set(USER_ID_KEY, "bob"); assert_eq!(require_user(&s).unwrap(), "bob"); } + + #[test] + fn session_is_admin_requires_user_for_causal_admin_guard_semantics() { + // Admin role without a user id is not a usable principal for force_archive. + let mut s = Session::new(); + s.set(ROLE_KEY, "admin"); + assert!(session_is_admin(&s)); + assert!(!session_has_user(&s)); + } } diff --git a/tests/e2e-ui/crates/service/src/host.rs b/tests/e2e-ui/crates/service/src/host.rs new file mode 100644 index 00000000..9f04d7c7 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/host.rs @@ -0,0 +1,155 @@ +//! One-screen host bootstrap for the e2e-ui application. +//! +//! Dialect selection and identity remain here. Outbox/consumer loops use +//! framework worker helpers. + +use std::sync::Arc; +use std::time::Duration; + +use distributed::bus::{PostgresBus, SqliteBus}; +use distributed::command_dispatch::LocalCommandDispatcher; +use distributed::graphql::IdentityConfig; +use distributed::microsvc::{spawn_outbox_publish_loop, spawn_service_consumer_loop}; +use distributed::{ + PostgresLockManager, PostgresRepository, SqliteLockManager, SqliteRepository, +}; + +use crate::{ + build_graphql_engine, build_service, distributed_manifest, serve_with_oidc, spawn_scrape_loop, + ZitadelScrapeConfig, E2E_UI_APPLICATION, +}; + +const BUS_GROUP: &str = "e2e-ui"; + +/// Bind address and identity for one local process host. +pub struct HostOptions { + pub bind: String, + pub identity: IdentityConfig, +} + +/// Start the e2e-ui full-local process for SQLite or Postgres from `DATABASE_URL`. +pub async fn run_e2e_host( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + eprintln!( + "e2e-ui host application=`{}` bind={}", + E2E_UI_APPLICATION, options.bind + ); + if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { + run_postgres(database_url, options).await + } else { + run_sqlite(database_url, options).await + } +} + +async fn run_sqlite( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + let repo = SqliteRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = SqliteLockManager::new(repo.pool().clone()); + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(SqliteBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-ui", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = SqliteBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!("e2e-ui (sqlite) listening on http://{}", options.bind); + serve_with_oidc(service, options.identity, &options.bind).await?; + Ok(()) +} + +async fn run_postgres( + database_url: &str, + options: HostOptions, +) -> Result<(), Box> { + let repo = PostgresRepository::connect_and_migrate(database_url).await?; + let registry = distributed_manifest() + .table_registry() + .map_err(|e| format!("manifest: {e}"))?; + repo.bootstrap_table_schema_for_dev(®istry).await?; + let locks = PostgresLockManager::new(repo.pool().clone()); + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + bus.ensure_tables().await?; + + let change_rx = repo.read_model_changes(); + let service = build_service(repo.clone(), locks.clone(), repo.clone()) + .with_bus(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)); + let gql = build_graphql_engine(&repo, &service, options.identity.clone(), Some(change_rx))?; + let service = Arc::new(service.try_with_graphql(gql)?); + let _dispatcher = Arc::new(LocalCommandDispatcher::new(Arc::clone(&service))); + + spawn_outbox_publish_loop( + repo.outbox_store(), + Arc::new(PostgresBus::new(repo.pool().clone()).group(BUS_GROUP)), + "e2e-ui", + Duration::from_secs(30), + 5, + ); + { + let repo = repo.clone(); + let locks = locks.clone(); + spawn_service_consumer_loop(move || { + let bus = PostgresBus::new(repo.pool().clone()).group(BUS_GROUP); + build_service(repo.clone(), locks.clone(), repo.clone()).with_bus(bus) + }); + } + spawn_zitadel_scrape(repo.clone()); + + eprintln!("e2e-ui (postgres) listening on http://{}", options.bind); + serve_with_oidc(service, options.identity, &options.bind).await?; + Ok(()) +} + +fn spawn_zitadel_scrape(repo: R) +where + R: distributed::TransactionalCommit + Clone + Send + Sync + 'static, +{ + match ZitadelScrapeConfig::from_env() { + Some(cfg) if cfg.background_enabled() || cfg.on_start => { + eprintln!( + "zitadel scrape: enabled (api={}, interval={}s, on_start={})", + cfg.api_base, + cfg.interval.as_secs(), + cfg.on_start + ); + spawn_scrape_loop(repo, cfg); + } + Some(_) => { + eprintln!( + "zitadel scrape: credentials present, background off (interval=0); use POST /zitadel.scrape.v1" + ); + } + None => { + eprintln!( + "zitadel scrape: disabled (set ZITADEL_SERVICE_USER_TOKEN + OIDC_ISSUER/ZITADEL_API_URL)" + ); + } + } +} diff --git a/tests/e2e-ui/crates/service/src/lib.rs b/tests/e2e-ui/crates/service/src/lib.rs index add5fd22..810761ae 100644 --- a/tests/e2e-ui/crates/service/src/lib.rs +++ b/tests/e2e-ui/crates/service/src/lib.rs @@ -1,26 +1,35 @@ -//! e2e-ui service library — handlers + GraphQL. +//! e2e-ui service library — application modules + host. //! -//! Domain logic lives in `*-domain`, query models and read RBAC in -//! `e2e-readmodels`, and portable event mappings in `e2e-projections`. This -//! crate owns the explicit command/projector handlers, placement, GraphQL, and -//! Zitadel ingress. Eventual read models are written by projector handlers; -//! eligible direct projections are written in the command transaction. +//! # Authoring story +//! +//! - [`application`] — surface names and module inventory +//! - [`modules`] — todo / chat / blob mounts + compose + GraphQL +//! - [`host`] — one-screen process bootstrap (`run_e2e_host`) +//! - [`handlers`] — command/event bodies (domain-adjacent, not infrastructure) +//! +//! Domain crates stay pure aggregates; read models live in `e2e-readmodels`; +//! portable projections in `e2e-projections`. +mod application; mod bounds; mod deps; pub mod handlers; +mod host; +pub mod modules; mod oidc_layer; -mod service; +pub use application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + E2E_UI_APPLICATION, E2E_UI_MODULE_IDS, +}; pub use e2e_readmodels::distributed_manifest; -/// Zitadel Management API scrape (reconcile missed Action events). pub use handlers::ingestors::zitadel::{ scrape_users_to_outbox, spawn_scrape_loop, ScrapeReport, ZitadelScrapeConfig, }; -pub use oidc_layer::serve_with_oidc; -pub use service::{ - build_graphql_engine, build_service, dev_identity, distributed_admin_client_surface, - distributed_client_surface, distributed_public_client_surface, identity_from_env, - oidc_bearer_config, DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, - DISTRIBUTED_PUBLIC_CLIENT_SURFACE, +pub use host::{run_e2e_host, HostOptions}; +pub use modules::compose::build_service; +pub use modules::graphql::{ + build_graphql_engine, dev_identity, distributed_admin_client_surface, distributed_client_surface, + distributed_public_client_surface, identity_from_env, oidc_bearer_config, }; +pub use oidc_layer::serve_with_oidc; diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs new file mode 100644 index 00000000..b7318e54 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -0,0 +1,94 @@ +//! Blob game module: Atomic command mounts (direct projection seal). + +use blob_domain::domain_commands; +use blob_domain::BlobGame; +use distributed::graphql::{ + Atomic, CommandProjectionPureReduce, SurfaceDirectProjection, +}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use e2e_readmodels::BlobGames; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers::commands::{blob_move, blob_start, blob_start_level}; +use crate::handlers::util::causal_has_user; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "blob"; + +type BlobRoutes = + Routes, BlobGame>, S>>; + +/// Mount blob Atomic commands. +/// +/// Emit sets come from domain transitions that directly capture events: +/// - start → [`domain_commands::StartWithMap`] (`blob.started`; demo start uses this path) +/// - move → [`domain_commands::MoveDir`] (`blob.moved`) +/// - start_level → [`domain_commands::StartLevel`] (`blob.level_started`) +pub fn routes( + repo: R, + locks: L, + read_models: S, + _blob_direct: SurfaceDirectProjection, +) -> BlobRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let _ = _blob_direct; + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::StartWithMap, + blob_start::BlobStartInput, + Atomic, + >(blob_start::COMMAND) + .field_name("blob_games_start") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, blob_start::handle) + .command_transition::< + domain_commands::MoveDir, + blob_move::BlobMoveInput, + Atomic, + >(blob_move::COMMAND) + .field_name("blob_games_move") + .roles(["user", "admin"].into_iter()) + // Domain pure: blob_domain::simulate_move — client twin at $lib/blob/simulate-move. + .preview_reduce_known_record( + CommandProjectionPureReduce::new( + "blob.simulate_move", + "blob/simulate-move", + "simulateMove", + "BlobGames", + ) + .key_input("game_id", ["game_id"]) + .arg_input("direction", ["direction"]) + .assign([ + "map_json", + "score", + "player_dead", + "current_level_completed", + "status", + ]), + ) + .guarded(causal_has_user, blob_move::handle) + .command_transition::< + domain_commands::StartLevel, + blob_start_level::BlobStartLevelInput, + Atomic, + >(blob_start_level::COMMAND) + .field_name("blob_games_start_level") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, blob_start_level::handle) +} diff --git a/tests/e2e-ui/crates/service/src/modules/chat.rs b/tests/e2e-ui/crates/service/src/modules/chat.rs new file mode 100644 index 00000000..ce643589 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/chat.rs @@ -0,0 +1,71 @@ +//! Chat + identity-ingestor module: room messages, Zitadel ingress, auth_user projector. + +use chat_domain::domain_commands; +use chat_domain::ChatMessage; +use distributed::graphql::{Eventual, SurfaceProjector}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; +use crate::handlers::commands::chat_post; +use crate::handlers::util::causal_has_user; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "chat"; + +type ChatRoutes = + Routes, ChatMessage>, S>>; + +/// Mount chat commands, Zitadel extension commands, and chat/auth projectors. +pub fn routes( + repo: R, + locks: L, + read_models: S, + chat_projector: SurfaceProjector, +) -> ChatRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::Post, + chat_post::ChatPostInput, + Eventual, + >(chat_post::COMMAND) + .field_name("chat_messages_post") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, chat_post::handle) + // Zitadel Action ingress + on-demand scrape remain non-GraphQL + // integration commands (explicit extension mounts). + .command(handlers::ingestors::zitadel::COMMAND) + .guarded( + handlers::ingestors::zitadel::guard, + handlers::ingestors::zitadel::handle, + ) + .command(handlers::ingestors::zitadel_scrape::COMMAND) + .guarded( + handlers::ingestors::zitadel_scrape::guard, + handlers::ingestors::zitadel_scrape::handle, + ) + .modeled_projector(chat_projector) + .handle(handlers::events::project_chat_messages::handle) + .events(handlers::events::project_auth_user::EVENTS) + .guarded( + handlers::events::project_auth_user::guard, + handlers::events::project_auth_user::handle, + ) +} diff --git a/tests/e2e-ui/crates/service/src/modules/compose.rs b/tests/e2e-ui/crates/service/src/modules/compose.rs new file mode 100644 index 00000000..cb3cf016 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/compose.rs @@ -0,0 +1,64 @@ +//! Compose bounded-context modules into one e2e-ui Service. + +use blob_domain::BlobGame; +use chat_domain::ChatMessage; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, Service, +}; +use distributed::{AggregateBuilder, AggregateRepository, QueuedRepository}; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::modules::{blob, chat, projections, todo}; + +/// Explicit module inventory for the e2e-ui application. +pub const MODULE_IDS: &[&str] = &[todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity"]; + +/// Compose todo + chat (+ identity ingestors) + blob modules into one Service. +/// +/// This is the review-visible application wiring: list modules, do not invent +/// infrastructure. Dialect runners and workers live in `host`. +pub fn build_service(repo: R, locks: L, read_models: S) -> Service +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, ChatMessage>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, + AggregateRepository, BlobGame>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + let projections = projections::projection_owners(); + let todos = todo::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.todo, + ); + let chat = chat::routes( + repo.clone(), + locks.clone(), + read_models.clone(), + projections.chat, + ); + let blob = blob::routes(repo, locks, read_models, projections.blob); + + // GraphQL-only public write surface (POST /todo.* must 404 — suite T0 / oidc_pg). + // Zitadel Action ingress still needs HTTP: those commands are registered in + // the chat module and re-mounted in `serve_with_oidc` when wildcards are off. + Service::new() + .named("e2e-ui") + .without_http_command_routes() + .routes(todos) + .routes(chat) + .routes(blob) +} diff --git a/tests/e2e-ui/crates/service/src/modules/graphql.rs b/tests/e2e-ui/crates/service/src/modules/graphql.rs new file mode 100644 index 00000000..a8a2251d --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/graphql.rs @@ -0,0 +1,669 @@ +use std::sync::Arc; + +use distributed::graphql::{ + build_surface, surface_for_application_contract, DistributedClientSurfaceExport, GraphqlEngine, + GraphqlPoolSource, IdentityConfig, OidcConfig, SurfaceOptions, +}; +use distributed::microsvc::Service; +use distributed::{InMemoryLockManager, InMemoryRepository, LockError, LockManager}; +use e2e_readmodels::{AuthUsers, BlobGames, ChatMessages, Todos}; + +use crate::application::{ + DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, +}; +use crate::modules::projections; + +// Stable only for this local copyable fixture. Real deployments must inject +// their own per-deployment key rather than copying this development value. +const E2E_PROTOCOL_TOKEN_KEY: [u8; 32] = [0xe2; 32]; + +#[derive(Clone, Default)] +pub(crate) struct ClientSurfaceLocks(Arc); + +impl LockManager for ClientSurfaceLocks { + type Lock = distributed::InMemoryLock; + + fn get_lock(&self, id: &str) -> Result, LockError> { + self.0.get_lock(id) + } +} + +/// GraphQL over todos + chat + blob + AuthUsers. +pub fn build_graphql_engine( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, +) -> Result { + build_graphql_engine_with_graphiql(pool, service, identity, change_rx, graphiql_enabled()) +} + +pub(crate) fn build_graphql_engine_with_graphiql( + pool: impl Into, + service: &Service, + identity: IdentityConfig, + change_rx: Option>, + graphiql: bool, +) -> Result { + let projections = projections::projection_owners(); + let mut b = GraphqlEngine::builder(pool) + .protocol_token_key(E2E_PROTOCOL_TOKEN_KEY) + .roles(&["user", "admin", "anonymous"]) + .client_application_surface_with_schema_roles( + DISTRIBUTED_CLIENT_SURFACE, + ["admin", "user"], + ["user"], + ) + .client_application_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, ["admin"], ["admin"]) + .client_application_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, ["anonymous"], ["anonymous"]) + .model::(Todos::permissions()) + .model::(ChatMessages::permissions()) + .model::(BlobGames::permissions()) + .model::(AuthUsers::permissions()) + .service(service) + .client_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .identity(identity) + .graphiql(graphiql); + if let Some(rx) = change_rx { + b = b.change_stream(rx); + } + b.build().map_err(|e| e.to_string()) +} + +fn pool_free_client_surface(application: &str, roles: &[&str]) -> DistributedClientSurfaceExport { + pool_free_client_surface_contract(application, roles, roles) +} + +fn pool_free_client_surface_contract( + application: &str, + eligible_roles: &[&str], + schema_roles: &[&str], +) -> DistributedClientSurfaceExport { + let project = e2e_readmodels::distributed_manifest(); + let repository = InMemoryRepository::new(); + let service = crate::modules::compose::build_service( + repository.clone(), + ClientSurfaceLocks::default(), + repository, + ); + let projections = projections::projection_owners(); + let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) + .expect("e2e-ui client Surface should build") + .with_projection_owners([ + projections.todo.into(), + projections.chat.into(), + projections.blob.into(), + ]) + .expect("e2e-ui projector topology should bind") + .with_service(&service) + .expect("e2e-ui typed Service inventory should bind"); + let eligible = eligible_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let schema = schema_roles + .iter() + .map(|role| (*role).to_string()) + .collect::>(); + let grants = e2e_readmodels::application_grants(); + let selected = surface_for_application_contract( + &full, + application, + &eligible, + &schema, + &grants, + ) + .expect("e2e-ui application Surface should select"); + DistributedClientSurfaceExport::from_selected("e2e-ui", selected) + .expect("e2e-ui application Surface should export") +} + +/// Pool-free normal application export consumed by `distributed client-manifest`. +pub fn distributed_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface_contract( + DISTRIBUTED_CLIENT_SURFACE, + &["admin", "user"], + &["user"], + ) +} + +pub fn distributed_admin_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, &["admin"]) +} + +pub fn distributed_public_client_surface() -> DistributedClientSurfaceExport { + pool_free_client_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) +} + +pub fn dev_identity() -> IdentityConfig { + IdentityConfig::dev_headers() +} + +pub fn graphiql_enabled() -> bool { + match std::env::var("GRAPHIQL") { + Ok(v) => { + let v = v.trim(); + !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) + } + Err(_) => true, + } +} + +fn env_clean(name: &str) -> String { + let mut s = std::env::var(name).unwrap_or_default().trim().to_string(); + for _ in 0..2 { + if s.len() >= 2 + && ((s.starts_with('\'') && s.ends_with('\'')) + || (s.starts_with('"') && s.ends_with('"'))) + { + s = s[1..s.len() - 1].trim().to_string(); + } else { + break; + } + } + s +} + +pub fn identity_from_env() -> IdentityConfig { + let iss = env_clean("OIDC_ISSUER"); + let aud = env_clean("OIDC_AUDIENCE"); + if iss.is_empty() || aud.is_empty() { + eprintln!("e2e-ui: OIDC_* unset — using DevHeaders (local only)"); + return dev_identity(); + } + let jwks = env_clean("OIDC_JWKS_URI"); + eprintln!("e2e-ui: OidcBearer issuer={iss} audience={aud}"); + oidc_bearer_config( + iss, + aud, + if jwks.is_empty() { None } else { Some(jwks) }, + None, + ) +} + +pub fn oidc_bearer_config( + issuer: impl Into, + audience: impl Into, + jwks_uri: Option, + static_jwks: Option, +) -> IdentityConfig { + let mut oidc = OidcConfig::new(issuer, audience); + if let Some(uri) = jwks_uri.filter(|s| !s.is_empty()) { + oidc.jwks_uri = Some(uri); + } + if let Some(jwks) = static_jwks { + oidc = oidc.with_static_jwks(jwks); + } + let cid = env_clean("OIDC_CLIENT_ID"); + if !cid.is_empty() { + oidc.extra_audiences = vec![cid]; + } + oidc.claim_map.engine_roles = vec!["user".into(), "admin".into()]; + oidc.claim_map.role_claims = vec![ + "groups".into(), + "roles".into(), + "realm_access.roles".into(), + "urn:zitadel:iam:org:project:roles".into(), + ]; + oidc.require_auth = false; + IdentityConfig::oidc_bearer(oidc) +} + +#[cfg(test)] +mod client_surface_tests { + use super::*; + use crate::application::{ + DISTRIBUTED_CLIENT_SURFACE, DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + }; + use crate::modules::compose::build_service; + use distributed::InMemoryRepository; + + #[test] + fn pool_free_user_and_admin_exports_compile_real_manifests() { + distributed_client_surface() + .manifest() + .expect("normal application client manifest"); + distributed_admin_client_surface() + .manifest() + .expect("elevated application client manifest"); + } + + #[test] + fn application_todos_keep_portable_owner_row_policy_for_optimistic_list_inserts() { + use distributed::graphql::ClientRowPolicy; + + let manifest = distributed_client_surface().manifest().unwrap(); + let todos = manifest + .models + .iter() + .find(|model| model.typename == "Todos") + .expect("Todos model on application surface"); + match &todos.row_policy { + ClientRowPolicy::Predicate { expression } => { + let text = serde_json::to_string(expression).expect("serialize row policy"); + assert!( + text.contains("x-user-id") && text.contains("owner_id"), + "owner claim predicate must be client-portable: {text}" + ); + } + other => panic!( + "Todos must not collapse to server-only row policy (blocks optimistic create list membership); got {other:?}" + ), + } + + let blob = manifest + .models + .iter() + .find(|model| model.typename == "BlobGames") + .expect("BlobGames model on application surface"); + assert!( + matches!(blob.row_policy, ClientRowPolicy::Predicate { .. }), + "BlobGames should keep portable owner row policy" + ); + } + + #[test] + fn todo_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::ClientProjectionPreviewSource; + + let manifest = distributed_client_surface().manifest().unwrap(); + let create = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_create") + .expect("todos_create command"); + let projection = create + .extensions + .projection + .as_ref() + .expect("todos_create must export projection extension"); + assert!( + !projection.preview_occurrences.is_empty(), + "auto-optimism must invent preview occurrences from emits + projection arms" + ); + let sources: Vec<_> = projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "create title must map from command input: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::GeneratedDefault { path } if path == &["todo_id"] + )), + "create todo_id must map from generated default: {sources:?}" + ); + assert!( + sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::TrustedPreset { name, codec } + if name == "x-user-id" && codec == "string" + )), + "create owner_id must map from row-policy claim: {sources:?}" + ); + + // Sparse update commands only need the known input slots. + let rename = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_rename") + .expect("todos_rename command"); + let rename_projection = rename + .extensions + .projection + .as_ref() + .expect("todos_rename projection"); + let rename_sources: Vec<_> = rename_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["title"] + )), + "rename title must map from input without .applies: {rename_sources:?}" + ); + assert!( + rename_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "rename todo_id must map from input without .applies: {rename_sources:?}" + ); + + let purge = manifest + .commands + .iter() + .find(|command| command.mutation_field == "todos_purge") + .expect("todos_purge command"); + let purge_projection = purge + .extensions + .projection + .as_ref() + .expect("todos_purge projection"); + let purge_sources: Vec<_> = purge_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + purge_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["todo_id"] + )), + "purge aggregate id must map from input without envelope .applies: {purge_sources:?}" + ); + } + + #[test] + fn chat_and_blob_commands_auto_derive_optimism_without_applies() { + use distributed::graphql::ClientProjectionPreviewSource; + + let manifest = distributed_client_surface().manifest().unwrap(); + + let post = manifest + .commands + .iter() + .find(|command| command.mutation_field == "chat_messages_post") + .expect("chat_messages_post command"); + let post_projection = post + .extensions + .projection + .as_ref() + .expect("chat post projection"); + let post_sources: Vec<_> = post_projection + .preview_occurrences + .iter() + .flat_map(|occurrence| occurrence.values.iter().map(|value| &value.source)) + .collect(); + assert!( + !post_projection.preview_occurrences.is_empty(), + "chat post must auto-derive preview occurrences" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["body"] + )), + "chat body from input: {post_sources:?}" + ); + assert!( + post_sources.iter().any(|source| matches!( + source, + ClientProjectionPreviewSource::Input { path } if path == &["message_id"] + )), + "chat message_id from input: {post_sources:?}" + ); + // ChatMessages has no owner-claim row policy (lobby is public-readable), so + // author_id is not auto-derived as TrustedPreset — remains Unknown until + // revalidation. That is intentional without a residual .applies map. + + let blob_move = manifest + .commands + .iter() + .find(|command| command.mutation_field == "blob_games_move") + .expect("blob_games_move command"); + let move_projection = blob_move + .extensions + .projection + .as_ref() + .expect("blob move projection"); + assert!( + !move_projection.preview_occurrences.is_empty(), + "blob move still exports projection arms for Atomic sealing" + ); + // Thin input: only game_id + direction. Board fields come from pure + // reduce (`blob.simulate_move` over the known cache row) + Atomic seal. + let move_input = match &blob_move.input { + distributed::graphql::ClientCommandShape::Object { definition } => definition, + other => panic!("blob move should be object input, got {other:?}"), + }; + let field_names: Vec<_> = move_input + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + assert_eq!( + field_names, + vec!["direction", "game_id"], + "blob move input must stay thin (no fat board fields on the wire)" + ); + } + + #[test] + fn chat_manifest_uses_unit_partition_so_lobby_live_can_stay_active() { + let manifest = distributed_client_surface().manifest().unwrap(); + let program = manifest + .projection_programs + .iter() + .find(|program| program.name == "project_chat_messages") + .expect("Chat projection program should be exported"); + assert!( + program.arms.iter().all(|arm| matches!( + &arm.partition, + distributed::graphql::ClientProjectionPartition::Unit + )), + "lobby chat uses unit partition so the chat_messages live query can advertise \ + supported index evidence (room isolation stays in the GraphQL where clause). \ + Surface-wide live_resume may still be false when owner-scoped models share the surface." + ); + } + + #[test] + fn blob_projection_owner_has_no_async_fact_route() { + let manifest = distributed_client_surface().manifest().unwrap(); + let owner = manifest + .projectors + .iter() + .find(|projector| projector.name == "project_blob") + .expect("Blob direct owner should be exported"); + assert!(owner.facts.is_empty()); + assert!(!owner.causal_confirmation); + + let repository = InMemoryRepository::new(); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository, + ); + let plan = service.subscription_plan(); + for event in [ + "todo.created", + "todo.renamed", + "todo.completed", + "todo.reopened", + "todo.archived", + "todo.force_archived", + "todo.purged", + "chat_message.posted", + ] { + assert!( + plan.events.iter().any(|candidate| candidate == event), + "eventual modeled projection must subscribe to {event}" + ); + } + for fact in [ + "blob.started", + "blob.initialized", + "blob.level_started", + "blob.moved", + ] { + assert!( + !plan.events.iter().any(|event| event == fact), + "direct-only Blob ownership must not register an async route for {fact}" + ); + } + } + + #[tokio::test] + async fn graphiql_does_not_change_the_postgres_runtime_client_manifest() { + let generated = distributed_client_surface().manifest().unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/distributed") + .unwrap(); + let repository = distributed::PostgresRepository::new(pool.clone()); + let service = build_service( + repository.clone(), + distributed::PostgresLockManager::new(pool), + repository.clone(), + ); + let engine = + crate::modules::graphql::build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, true) + .expect("engine"); + let runtime = engine + .client_manifest_for_application( + DISTRIBUTED_CLIENT_SURFACE, + &["admin", "user"], + &["user"], + ) + .unwrap(); + + assert_eq!(generated, runtime); + + let make_request = || { + serde_json::from_value(serde_json::json!({ + "query": "{ todos @skip(if: true) { todo_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_CLIENT_SURFACE, + "eligible_roles": ["admin", "user"], + "schema_roles": ["user"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("generated application request") + }; + let mut session = distributed::microsvc::Session::new(); + session.set("x-roles", "user"); + session.set("x-user-id", "person-1"); + let response = engine.execute(&session, make_request()).await; + assert!( + !response.is_err(), + "the runtime must accept the generated application surface: {:?}", + response.errors + ); + // Multi-role admin principal may open the same portable contract. + let mut admin = session.clone(); + admin.set("x-roles", "admin,user"); + let admin_response = engine.execute(&admin, make_request()).await; + assert!( + !admin_response.is_err(), + "admin with user asserted roles must open e2e-ui: {:?}", + admin_response.errors + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!( + envelope["schemaHash"], generated.schema_fingerprint, + "the authoritative response must attest the generated schema" + ); + } + + /// Empty-session open of e2e-ui-public + chat query (anonymous privilege). + /// + /// Bare protocol path for unauthenticated lobby peeks; UI route `/public` + /// documents the same surface name and extension shape. + #[tokio::test] + async fn public_surface_opens_and_queries_chat_without_identity() { + let generated = distributed_public_client_surface().manifest().unwrap(); + assert_eq!( + generated.surface, + distributed::graphql::ClientSurfaceIdentity::application( + DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + ["anonymous"], + ["anonymous"], + ) + ); + let repository = distributed::SqliteRepository::connect_and_migrate("sqlite::memory:") + .await + .expect("sqlite memory repo"); + let registry = e2e_readmodels::distributed_manifest() + .table_registry() + .expect("registry"); + repository + .bootstrap_table_schema_for_dev(®istry) + .await + .expect("bootstrap tables"); + let service = build_service( + repository.clone(), + crate::modules::graphql::ClientSurfaceLocks::default(), + repository.clone(), + ); + let engine = + crate::modules::graphql::build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, false) + .expect("engine"); + let runtime = engine + .client_manifest_for_application(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"], &["anonymous"]) + .expect("public surface registered"); + assert_eq!(generated.schema_fingerprint, runtime.schema_fingerprint); + + let request = serde_json::from_value(serde_json::json!({ + "query": "{ chat_messages(limit: 5, offset: 0) { message_id body room_id } }", + "extensions": { + "distributed": { + "client": { + "surface": { + "kind": "application", + "name": DISTRIBUTED_PUBLIC_CLIENT_SURFACE, + "eligible_roles": ["anonymous"], + "schema_roles": ["anonymous"] + }, + "schemaHash": generated.schema_fingerprint + } + } + } + })) + .expect("public application request"); + + // No x-user-id, no x-roles — unauthenticated principal. + let session = distributed::microsvc::Session::new(); + let response = engine.execute(&session, request).await; + assert!( + !response.is_err(), + "anonymous open + chat query must succeed: {:?}", + response.errors + ); + let data = response.data.into_json().expect("json data"); + assert!( + data.get("chat_messages").and_then(|v| v.as_array()).is_some(), + "expected chat_messages array: {data}" + ); + let envelope = response + .extensions + .get("distributed") + .expect("distributed protocol envelope"); + let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); + assert_eq!(envelope["schemaHash"], generated.schema_fingerprint); + } + + #[test] + fn module_inventory_lists_todo_chat_blob_identity() { + assert_eq!( + crate::E2E_UI_MODULE_IDS, + &["todo", "chat", "blob", "identity"] + ); + assert_eq!(crate::application::MODULE_DECLARATIONS.len(), 4); + } +} \ No newline at end of file diff --git a/tests/e2e-ui/crates/service/src/modules/mod.rs b/tests/e2e-ui/crates/service/src/modules/mod.rs new file mode 100644 index 00000000..315d1d82 --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/mod.rs @@ -0,0 +1,11 @@ +//! Bounded-context application modules for e2e-ui. +//! +//! Each module owns its command/projection mounts. [`compose`] lists them +//! into one Service; [`graphql`] owns surfaces and the query engine. + +pub mod blob; +pub mod chat; +pub mod compose; +pub mod graphql; +pub mod projections; +pub mod todo; diff --git a/tests/e2e-ui/crates/service/src/modules/projections.rs b/tests/e2e-ui/crates/service/src/modules/projections.rs new file mode 100644 index 00000000..69b4723c --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/projections.rs @@ -0,0 +1,43 @@ +//! e2e-ui projection mounts — product declaration only. +//! +//! Topology, catalog activation, and Surface packaging come from +//! [`distributed::LocalProjectionMountsBuilder`]. + +use distributed::graphql::{SurfaceDirectProjection, SurfaceProjector}; +use distributed::LocalProjectionMountsBuilder; +use e2e_projections::{BLOB_GAMES, CHAT_MESSAGES, TODOS}; +use e2e_readmodels::{BlobGames, ChatMessages, Todos}; + +/// Projection surface mounts used by compose + GraphQL. +#[derive(Clone)] +pub struct ProjectionOwners { + pub todo: SurfaceProjector, + pub chat: SurfaceProjector, + pub blob: SurfaceDirectProjection, +} + +/// Compile local projection mounts for the e2e-ui application. +pub fn projection_owners() -> ProjectionOwners { + let mounts = LocalProjectionMountsBuilder::new("e2e-ui", "ordered-domain-events") + .expect("projection source") + .eventual_model::("project_todos", TODOS, "e2e-ui-todos-v2") + .expect("todo mount") + .eventual_model::("project_chat_messages", CHAT_MESSAGES, "e2e-ui-chat-v2") + .expect("chat mount") + .direct_model::("project_blob", BLOB_GAMES, "e2e-ui-blob-v2") + .expect("blob mount") + .build() + .expect("projection catalog"); + + ProjectionOwners { + todo: mounts + .projector("project_todos") + .expect("todo projector"), + chat: mounts + .projector("project_chat_messages") + .expect("chat projector"), + blob: mounts + .direct_projection("project_blob") + .expect("blob direct"), + } +} diff --git a/tests/e2e-ui/crates/service/src/modules/todo.rs b/tests/e2e-ui/crates/service/src/modules/todo.rs new file mode 100644 index 00000000..e57a38ea --- /dev/null +++ b/tests/e2e-ui/crates/service/src/modules/todo.rs @@ -0,0 +1,111 @@ +//! Todo bounded-context module: command mounts + eventual projector. + +use distributed::graphql::{Eventual, SurfaceProjector}; +use distributed::microsvc::{ + ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, RepoReadModelDependencies, Routes, +}; +use distributed::{ + command_input_defaults, AggregateBuilder, AggregateRepository, QueuedRepository, +}; +use todo_domain::domain_commands; +use todo_domain::Todo; + +use crate::bounds::{EventStore, Locks, ReadStore}; +use crate::handlers; +use crate::handlers::commands::{ + payloads, todo_archive, todo_complete, todo_create, todo_force_archive, todo_purge, todo_rename, + todo_reopen, +}; +use crate::handlers::util::{causal_has_user, causal_is_admin}; + +/// Logical module id for composition inventories. +pub const MODULE_ID: &str = "todo"; + +type TodoRoutes = + Routes, Todo>, S>>; + +/// Mount todo commands and the todo projector. +pub fn routes( + repo: R, + locks: L, + read_models: S, + todo_projector: SurfaceProjector, +) -> TodoRoutes +where + R: EventStore, + L: Locks, + S: ReadStore, + QueuedRepository: Clone + + AggregateBuilder + + HasOutboxStore + + distributed::TransactionalCommit + + Send + + Sync + + 'static, + AggregateRepository, Todo>: + HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, +{ + Routes::for_aggregate::(repo, locks, read_models) + .command_transition::< + domain_commands::Create, + todo_create::TodoCreateInput, + Eventual, + >(todo_create::COMMAND) + .field_name("todos_create") + .roles(["user", "admin"].into_iter()) + .input_defaults(command_input_defaults! { + input: todo_create::TodoCreateInput; + default input.todo_id = uuid_v7(); + }) + .guarded(causal_has_user, todo_create::handle) + .command_transition::< + domain_commands::Rename, + todo_rename::TodoRenameInput, + Eventual, + >(todo_rename::COMMAND) + .field_name("todos_rename") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_rename::handle) + .command_transition::< + domain_commands::Complete, + todo_complete::TodoCompleteInput, + Eventual, + >(todo_complete::COMMAND) + .field_name("todos_complete") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_complete::handle) + .command_transition::< + domain_commands::Reopen, + todo_reopen::TodoReopenInput, + Eventual, + >(todo_reopen::COMMAND) + .field_name("todos_reopen") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_reopen::handle) + .command_transition::< + domain_commands::Archive, + todo_archive::TodoArchiveInput, + Eventual, + >(todo_archive::COMMAND) + .field_name("todos_archive") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_archive::handle) + .command_transition::< + domain_commands::ForceArchive, + todo_force_archive::TodoForceArchiveInput, + Eventual, + >(todo_force_archive::COMMAND) + .field_name("todos_force_archive") + .roles(["admin"]) + .guarded(causal_is_admin, todo_force_archive::handle) + .command_transition::< + domain_commands::Purge, + todo_purge::TodoPurgeInput, + Eventual, + >(todo_purge::COMMAND) + .field_name("todos_purge") + .roles(["user", "admin"].into_iter()) + .guarded(causal_has_user, todo_purge::handle) + .modeled_projector(todo_projector) + .handle(handlers::events::project_todos::handle) +} diff --git a/tests/e2e-ui/crates/service/src/service.rs b/tests/e2e-ui/crates/service/src/service.rs deleted file mode 100644 index 364e1265..00000000 --- a/tests/e2e-ui/crates/service/src/service.rs +++ /dev/null @@ -1,946 +0,0 @@ -//! Route bundles + GraphQL engine for the e2e-ui fixture. - -use std::sync::Arc; - -use blob_domain::{ - BlobGame, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, BlobStartedDomainEvent, -}; -use chat_domain::{ChatMessage, ChatMessagePostedDomainEvent}; -use distributed::graphql::{ - build_surface, typed_command, Eventual, CommandProjectionPreview, - CommandProjectionPreviewSource, DistributedClientSurfaceExport, GraphqlEngine, - GraphqlPoolSource, IdentityConfig, OidcConfig, Atomic, SurfaceDirectProjection, - SurfaceModeledProjection, SurfaceOptions, SurfaceProjector, -}; -use distributed::microsvc::{ - ConfigurableOutboxPublisher, HasOutboxStore, HasRepo, Routes, Service, -}; -use distributed::projection::catalog::{ProjectionBindingActivation, ProjectionCatalog}; -use distributed::projection::lower::ProjectionDescriptor; -use distributed::projection::placement::{ - ProjectionBinding, ProjectionBindingState, ProjectionEpoch, ProjectionExecutorRoute, - ProjectionOutput, ProjectionOwner, ProjectionPhysicalTopology, ProjectionSourceBinding, - PROJECTION_PARTITION_CODEC_VERSION, -}; -use distributed::projection_protocol::ProjectorTopologyId; -use distributed::{ - command_input_defaults, AggregateBuilder, AggregateRepository, InMemoryLockManager, - InMemoryRepository, LockError, LockManager, ProjectionEnvelopeField, Queueable, - QueuedRepository, RelationalReadModel, -}; -use e2e_projections::{BLOB_GAMES, CHAT_MESSAGES, TODOS}; -use e2e_readmodels::{AuthUsers, BlobGames, ChatMessages, Todos}; -use todo_domain::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, TodoRenamedDomainEvent, - TodoReopenedDomainEvent, -}; - -use crate::bounds::{EventStore, Locks, ReadStore}; -use crate::handlers; - -// Stable only for this local copyable fixture. Real deployments must inject -// their own per-deployment key rather than copying this development value. -const E2E_PROTOCOL_TOKEN_KEY: [u8; 32] = [0xe2; 32]; - -/// Stable normal-application surface shared by user and admin sessions. -pub const DISTRIBUTED_CLIENT_SURFACE: &str = "e2e-ui"; -/// Stable elevated surface for routes that intentionally include admin-only fields. -pub const DISTRIBUTED_ADMIN_CLIENT_SURFACE: &str = "e2e-ui-admin"; -/// Unauthenticated public surface (lobby message peek). -pub const DISTRIBUTED_PUBLIC_CLIENT_SURFACE: &str = "e2e-ui-public"; - -#[derive(Clone, Default)] -struct ClientSurfaceLocks(Arc); - -impl LockManager for ClientSurfaceLocks { - type Lock = distributed::InMemoryLock; - - fn get_lock(&self, id: &str) -> Result, LockError> { - self.0.get_lock(id) - } -} - -#[derive(Clone)] -struct ProjectionOwners { - todo: SurfaceProjector, - chat: SurfaceProjector, - blob: SurfaceDirectProjection, -} - -fn projection_output() -> ProjectionOutput { - let schema = M::schema().clone(); - ProjectionOutput::try_new(schema.model_name.clone(), schema.table_name.clone(), schema) - .expect("canonical e2e-ui projection output") -} - -fn physical_topology(name: &str, digest: u8) -> ProjectionPhysicalTopology { - ProjectionPhysicalTopology::from_protocol( - &ProjectorTopologyId::new(1, name, [digest; 32]) - .expect("canonical e2e-ui physical topology"), - ) -} - -fn modeled_projection( - descriptor: ProjectionDescriptor, - catalog: &ProjectionCatalog, - active: &distributed::projection::catalog::ActiveProjectionBindings, - binding: &ProjectionBinding, -) -> SurfaceModeledProjection { - SurfaceModeledProjection::try_from_descriptor(descriptor, catalog, active, binding.id()) - .expect("modeled projection should resolve through the active catalog") -} - -fn projection_owners() -> ProjectionOwners { - let source = || { - ProjectionSourceBinding::try_new("e2e-ui-domain", "ordered-domain-events", 1) - .expect("canonical e2e-ui domain source") - }; - let owner = |name| ProjectionOwner::try_new(name).expect("canonical projection owner"); - - let todo_binding = ProjectionBinding::materialize_eventual( - TODOS.eventual(), - source(), - owner("project_todos"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_todos", 0x20)), - ) - .expect("Todo projection binding"); - let chat_binding = ProjectionBinding::materialize_eventual( - CHAT_MESSAGES.eventual(), - source(), - owner("project_chat_messages"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_chat_messages", 0x21)), - ) - .expect("Chat projection binding"); - let blob_binding = ProjectionBinding::materialize_direct( - BLOB_GAMES.direct(), - source(), - owner("project_blob"), - "distributed-projection-partition", - PROJECTION_PARTITION_CODEC_VERSION, - vec![projection_output::()], - Vec::new(), - Some(physical_topology("project_blob", 0x22)), - ) - .expect("Blob projection binding"); - // Blob projected commands stage the mutation-derived row in the handler - // (`readmodel(row).commit()?.atomic()`). Binding/catalog still own - // ownership, replay, and async projection for BLOB_GAMES. - - let catalog = ProjectionCatalog::try_new(vec![ - todo_binding.clone(), - chat_binding.clone(), - blob_binding.clone(), - ]) - .expect("deployment-wide projection catalog"); - let activation = |binding: &ProjectionBinding, epoch: &str| { - ProjectionBindingActivation::new( - binding.id(), - binding.program_id(), - ProjectionEpoch::new(epoch).expect("canonical projection epoch"), - ProjectionBindingState::Active, - Some( - ProjectionExecutorRoute::local("e2e-ui").expect("canonical local projection route"), - ), - ) - }; - let active = catalog - .activate( - vec![ - activation(&todo_binding, "e2e-ui-todos-v2"), - activation(&chat_binding, "e2e-ui-chat-v2"), - activation(&blob_binding, "e2e-ui-blob-v2"), - ], - None, - ) - .expect("non-overlapping active projection catalog"); - - // Runtime mounts are mutation-backed: descriptor program factories must - // match the mutation rewrite programs (real path, not digest theater). - - - - - ProjectionOwners { - todo: SurfaceProjector::new("project_todos").modeled(modeled_projection( - TODOS, - &catalog, - &active, - &todo_binding, - )), - chat: SurfaceProjector::new("project_chat_messages").modeled(modeled_projection( - CHAT_MESSAGES, - &catalog, - &active, - &chat_binding, - )), - blob: SurfaceDirectProjection::new("project_blob").modeled(modeled_projection( - BLOB_GAMES, - &catalog, - &active, - &blob_binding, - )), - } -} - -fn pool_free_client_surface(application: &str, roles: &[&str]) -> DistributedClientSurfaceExport { - pool_free_client_surface_contract(application, roles, roles) -} - -fn pool_free_client_surface_contract( - application: &str, - eligible_roles: &[&str], - schema_roles: &[&str], -) -> DistributedClientSurfaceExport { - use distributed::graphql::surface_for_application_contract; - - let project = e2e_readmodels::distributed_manifest(); - let repository = InMemoryRepository::new(); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository, - ); - let projections = projection_owners(); - let full = build_surface(&project.tables, &SurfaceOptions::sqlite()) - .expect("e2e-ui client Surface should build") - .with_projection_owners([ - projections.todo.into(), - projections.chat.into(), - projections.blob.into(), - ]) - .expect("e2e-ui projector topology should bind") - .with_service(&service) - .expect("e2e-ui typed Service inventory should bind"); - let eligible = eligible_roles - .iter() - .map(|role| (*role).to_string()) - .collect::>(); - let schema = schema_roles - .iter() - .map(|role| (*role).to_string()) - .collect::>(); - // Schema grants: only schema_roles need entries in the map. - let grants = e2e_readmodels::application_grants(); - let selected = surface_for_application_contract( - &full, - application, - &eligible, - &schema, - &grants, - ) - .expect("e2e-ui application Surface should select"); - DistributedClientSurfaceExport::from_project(&project, selected) - .expect("e2e-ui application Surface should export") -} - -/// Pool-free normal application export consumed by `dctl client-manifest`. -/// -/// **Eligible roles** are `admin` + `user` so multi-role admin principals can -/// open the normal app client. **Schema privilege** is the `user` grant set -/// only so owner-scoped models (`Todos`, `BlobGames`) keep a **client-portable** -/// row policy (`owner_id = claim(x-user-id)`) for optimistic list inserts. -/// -/// Server-side admin GraphQL grants are unchanged — an admin session still -/// receives unrestricted query results via the concrete admin role surface. -/// Elevated all-rows views / force-archive stay on -/// [`distributed_admin_client_surface`]. -pub fn distributed_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface_contract( - DISTRIBUTED_CLIENT_SURFACE, - &["admin", "user"], - &["user"], - ) -} - -/// Pool-free elevated application export for admin-only routes. -pub fn distributed_admin_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, &["admin"]) -} - -/// Pool-free public (anonymous) application export for unauthenticated lobby peeks. -pub fn distributed_public_client_surface() -> DistributedClientSurfaceExport { - pool_free_client_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) -} - -/// Full service: todo + chat commands and projectors. -pub fn build_service(repo: R, locks: L, read_models: S) -> Service -where - R: EventStore, - L: Locks, - S: ReadStore, - QueuedRepository: Clone - + AggregateBuilder - + HasOutboxStore - + distributed::TransactionalCommit - + Send - + Sync - + 'static, - AggregateRepository, Todo>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, - AggregateRepository, ChatMessage>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, - AggregateRepository, BlobGame>: - HasRepo + HasOutboxStore + ConfigurableOutboxPublisher + Send + Sync + 'static, -{ - use handlers::commands::{ - blob_move, blob_start, blob_start_level, chat_post, payloads, todo_archive, todo_complete, - todo_create, todo_force_archive, todo_purge, todo_rename, todo_reopen, - }; - - let app_roles = ["user", "admin"]; - let projections = projection_owners(); - let todos = Routes::new() - .with_repo(repo.clone().queued_with(locks.clone()).aggregate::()) - .with_read_model_store(read_models.clone()) - .typed_command( - typed_command::>( - todo_create::COMMAND, - ) - .field_name("todos_create") - .roles(app_roles) - .input_defaults(command_input_defaults! { - input: todo_create::TodoCreateInput; - default input.todo_id = uuid_v7(); - }) - .emits(distributed::events![TodoCreatedDomainEvent]) - .applies(distributed::state_preview! { - TodoCreatedDomainEvent => todo_domain::TodoState { - todo_id: generated.todo_id, - owner_id: trusted("x-user-id", "string"), - title: input.title, - status: "open", - assignee_id: null, - } - }), - ) - .handle(todo_create::handle) - .typed_command( - typed_command::>( - todo_rename::COMMAND, - ) - .field_name("todos_rename") - .roles(app_roles) - .emits(distributed::events![TodoRenamedDomainEvent]) - .applies(distributed::state_preview! { - TodoRenamedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - title: input.title, - ..unknown - } - }), - ) - .handle(todo_rename::handle) - .typed_command( - typed_command::>( - todo_complete::COMMAND, - ) - .field_name("todos_complete") - .roles(app_roles) - .emits(distributed::events![TodoCompletedDomainEvent]) - .applies(distributed::state_preview! { - TodoCompletedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "completed", - ..unknown - } - }), - ) - .handle(todo_complete::handle) - .typed_command( - typed_command::>( - todo_reopen::COMMAND, - ) - .field_name("todos_reopen") - .roles(app_roles) - .emits(distributed::events![TodoReopenedDomainEvent]) - .applies(distributed::state_preview! { - TodoReopenedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "open", - ..unknown - } - }), - ) - .handle(todo_reopen::handle) - .typed_command( - typed_command::>( - todo_archive::COMMAND, - ) - .field_name("todos_archive") - .roles(app_roles) - .emits(distributed::events![TodoArchivedDomainEvent]) - .applies(distributed::state_preview! { - TodoArchivedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "archived", - ..unknown - } - }), - ) - .handle(todo_archive::handle) - .typed_command( - typed_command::< - todo_force_archive::TodoForceArchiveInput, - Eventual, - >(todo_force_archive::COMMAND) - .field_name("todos_force_archive") - .roles(["admin"]) - .emits(distributed::events![TodoForceArchivedDomainEvent]) - .applies(distributed::state_preview! { - TodoForceArchivedDomainEvent => todo_domain::TodoState { - todo_id: input.todo_id, - status: "archived", - ..unknown - } - }), - ) - .handle(todo_force_archive::handle) - .typed_command( - typed_command::>(todo_purge::COMMAND) - .field_name("todos_purge") - .roles(app_roles) - .emits(distributed::events![TodoPurgedDomainEvent]) - .applies( - CommandProjectionPreview::new() - .events(distributed::events![TodoPurgedDomainEvent]) - .envelope( - ProjectionEnvelopeField::AggregateId, - CommandProjectionPreviewSource::input(["todo_id"]), - ), - ), - ) - .handle(todo_purge::handle) - .modeled_projector(projections.todo.clone()) - .handle(handlers::events::project_todos::handle); - - let chat = Routes::new() - .with_repo( - repo.clone() - .queued_with(locks.clone()) - .aggregate::(), - ) - .with_read_model_store(read_models.clone()) - .typed_command( - typed_command::>( - chat_post::COMMAND, - ) - .field_name("chat_messages_post") - .roles(app_roles) - .emits(distributed::events![ChatMessagePostedDomainEvent]) - .applies(distributed::state_preview! { - ChatMessagePostedDomainEvent => chat_domain::ChatMessageState { - message_id: input.message_id, - room_id: input.room_id, - author_id: trusted("x-user-id", "string"), - body: input.body, - created_at: input.created_at, - } - }), - ) - .handle(chat_post::handle) - // Zitadel Action ingress + on-demand scrape remain non-GraphQL - // integration commands. - .command(handlers::ingestors::zitadel::COMMAND) - .guarded( - handlers::ingestors::zitadel::guard, - handlers::ingestors::zitadel::handle, - ) - .command(handlers::ingestors::zitadel_scrape::COMMAND) - .guarded( - handlers::ingestors::zitadel_scrape::guard, - handlers::ingestors::zitadel_scrape::handle, - ) - .modeled_projector(projections.chat.clone()) - .handle(handlers::events::project_chat_messages::handle) - .events(handlers::events::project_auth_user::EVENTS) - .guarded( - handlers::events::project_auth_user::guard, - handlers::events::project_auth_user::handle, - ); - - let blob = Routes::new() - .with_repo(repo.queued_with(locks).aggregate::()) - .with_read_model_store(read_models) - .typed_command( - typed_command::>(blob_start::COMMAND) - .field_name("blob_games_start") - .roles(app_roles) - .emits(distributed::events![BlobStartedDomainEvent]) - // Same mutation IR as eventual. Atomic waits for the handler - // row and returns it (confirmDirectProjection). `.applies` is - // optional pre-network shell — map_json is RNG server-side. - .applies(distributed::state_preview! { - BlobStartedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: 0, - player_dead: unknown, - current_level: 1, - current_level_completed: unknown, - map_json: "[]", - status: "active", - } - }), - ) - .handle(blob_start::handle) - .typed_command( - typed_command::>(blob_move::COMMAND) - .field_name("blob_games_move") - .roles(app_roles) - .emits(distributed::events![BlobMovedDomainEvent]) - // Same client path as todos/chat: `.applies` maps command input - // into the optimistic layer. Client fills board fields from the - // pure simulate_move twin; server recomputes via domain. - .applies(distributed::state_preview! { - BlobMovedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: input.score, - player_dead: input.player_dead, - current_level: input.current_level, - current_level_completed: input.current_level_completed, - map_json: input.map_json, - status: input.status, - } - }), - ) - .handle(blob_move::handle) - .typed_command( - typed_command::>( - blob_start_level::COMMAND, - ) - .field_name("blob_games_start_level") - .roles(app_roles) - .emits(distributed::events![BlobLevelStartedDomainEvent]) - .applies(distributed::state_preview! { - BlobLevelStartedDomainEvent => blob_domain::BlobGameState { - game_id: input.game_id, - owner_id: trusted("x-user-id", "string"), - score: unknown, - player_dead: unknown, - current_level: unknown, - current_level_completed: unknown, - map_json: "[]", - status: "active", - } - }), - ) - .handle(blob_start_level::handle); - - // GraphQL-only public write surface (POST /todo.* must 404 — suite T0). - // Zitadel Action ingress still needs HTTP: those commands are registered above and - // re-mounted explicitly in `serve_with_oidc` when HTTP command wildcards are off. - Service::new() - .named("e2e-ui") - .without_http_command_routes() - .routes(todos) - .routes(chat) - .routes(blob) -} - -/// GraphQL over todos (owner-scoped) + chat_messages (shared room, live subscriptions). -/// -/// All write paths are **command mutations** (not read-model writes). Owner/author is -/// always the authenticated session principal. Roles: user, admin. -/// -/// Works with SQLite or Postgres pools through [`GraphqlPoolSource`]. -pub fn build_graphql_engine( - pool: impl Into, - service: &Service, - identity: IdentityConfig, - change_rx: Option>, -) -> Result { - build_graphql_engine_with_graphiql(pool, service, identity, change_rx, graphiql_enabled()) -} - -fn build_graphql_engine_with_graphiql( - pool: impl Into, - service: &Service, - identity: IdentityConfig, - change_rx: Option>, - graphiql: bool, -) -> Result { - let projections = projection_owners(); - let mut b = GraphqlEngine::builder(pool) - .protocol_token_key(E2E_PROTOCOL_TOKEN_KEY) - .roles(&["user", "admin", "anonymous"]) - // e2e-ui: eligible admin+user (multi-role principals may open); schema - // privilege remains user-only so owner-scoped models keep portable row - // policies for optimistic list inserts (see distributed_client_surface). - // e2e-ui-admin: elevated ops / all-rows views (/admin). - // e2e-ui-public: unauthenticated lobby read (anonymous privilege). - .client_application_surface_with_schema_roles( - DISTRIBUTED_CLIENT_SURFACE, - ["admin", "user"], - ["user"], - ) - .client_application_surface(DISTRIBUTED_ADMIN_CLIENT_SURFACE, ["admin"]) - .client_application_surface(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, ["anonymous"]) - // user: only own rows. admin: all owners (UI: /admin all-notes view). - .model::(Todos::permissions()) - .model::(ChatMessages::permissions()) - .model::(BlobGames::permissions()) - // Imported IdP directory (join target for todo/blob owner and chat author). - // Readable by all authenticated roles; writes only via Zitadel projector. - .model::(AuthUsers::permissions()) - .service(service) - .client_projection_owners([ - projections.todo.into(), - projections.chat.into(), - projections.blob.into(), - ]) - .identity(identity) - // GraphiQL is a local template convenience. Disable with GRAPHIQL=0 - // (never ship a public edge with GraphiQL + DevHeaders). - .graphiql(graphiql); - if let Some(rx) = change_rx { - b = b.change_stream(rx); - } - b.build().map_err(|e| e.to_string()) -} - -pub fn dev_identity() -> IdentityConfig { - IdentityConfig::dev_headers() -} - -/// GraphiQL IDE: on by default for the fixture; set `GRAPHIQL=0` to disable. -pub fn graphiql_enabled() -> bool { - match std::env::var("GRAPHIQL") { - Ok(v) => { - let v = v.trim(); - !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) - } - Err(_) => true, - } -} - -/// Peel accidental outer quotes from env values (Make-include / double-wrap pollution). -fn env_clean(name: &str) -> String { - let mut s = std::env::var(name).unwrap_or_default().trim().to_string(); - for _ in 0..2 { - if s.len() >= 2 - && ((s.starts_with('\'') && s.ends_with('\'')) - || (s.starts_with('"') && s.ends_with('"'))) - { - s = s[1..s.len() - 1].trim().to_string(); - } else { - break; - } - } - s -} - -/// Prefer OidcBearer when `OIDC_ISSUER` + `OIDC_AUDIENCE` are set; else DevHeaders. -pub fn identity_from_env() -> IdentityConfig { - let iss = env_clean("OIDC_ISSUER"); - let aud = env_clean("OIDC_AUDIENCE"); - if iss.is_empty() || aud.is_empty() { - eprintln!("e2e-ui: OIDC_* unset — using DevHeaders (local only)"); - return dev_identity(); - } - let jwks = env_clean("OIDC_JWKS_URI"); - eprintln!("e2e-ui: OidcBearer issuer={iss} audience={aud}"); - oidc_bearer_config( - iss, - aud, - if jwks.is_empty() { None } else { Some(jwks) }, - None, - ) -} - -pub fn oidc_bearer_config( - issuer: impl Into, - audience: impl Into, - jwks_uri: Option, - static_jwks: Option, -) -> IdentityConfig { - let mut oidc = OidcConfig::new(issuer, audience); - if let Some(uri) = jwks_uri.filter(|s| !s.is_empty()) { - oidc.jwks_uri = Some(uri); - } - if let Some(jwks) = static_jwks { - oidc = oidc.with_static_jwks(jwks); - } - // Accept client_id as extra audience when present (human OIDC access tokens). - let cid = env_clean("OIDC_CLIENT_ID"); - if !cid.is_empty() { - oidc.extra_audiences = vec![cid]; - } - oidc.claim_map.engine_roles = vec!["user".into(), "admin".into()]; - oidc.claim_map.role_claims = vec![ - "groups".into(), - "roles".into(), - "realm_access.roles".into(), - "urn:zitadel:iam:org:project:roles".into(), - ]; - // Allow empty identity so e2e-ui-public (anonymous) can open without a Bearer. - // Invalid/malformed tokens still 401. - oidc.require_auth = false; - IdentityConfig::oidc_bearer(oidc) -} - -#[cfg(test)] -mod client_surface_tests { - use super::*; - - #[test] - fn pool_free_user_and_admin_exports_compile_real_manifests() { - distributed_client_surface() - .manifest() - .expect("normal application client manifest"); - distributed_admin_client_surface() - .manifest() - .expect("elevated application client manifest"); - } - - #[test] - fn application_todos_keep_portable_owner_row_policy_for_optimistic_list_inserts() { - use distributed::graphql::ClientRowPolicy; - - let manifest = distributed_client_surface().manifest().unwrap(); - let todos = manifest - .models - .iter() - .find(|model| model.typename == "Todos") - .expect("Todos model on application surface"); - match &todos.row_policy { - ClientRowPolicy::Predicate { expression } => { - let text = serde_json::to_string(expression).expect("serialize row policy"); - assert!( - text.contains("x-user-id") && text.contains("owner_id"), - "owner claim predicate must be client-portable: {text}" - ); - } - other => panic!( - "Todos must not collapse to server-only row policy (blocks optimistic create list membership); got {other:?}" - ), - } - - let blob = manifest - .models - .iter() - .find(|model| model.typename == "BlobGames") - .expect("BlobGames model on application surface"); - assert!( - matches!(blob.row_policy, ClientRowPolicy::Predicate { .. }), - "BlobGames should keep portable owner row policy" - ); - } - - #[test] - fn chat_manifest_uses_unit_partition_so_lobby_live_can_stay_active() { - let manifest = distributed_client_surface().manifest().unwrap(); - let program = manifest - .projection_programs - .iter() - .find(|program| program.name == "project_chat_messages") - .expect("Chat projection program should be exported"); - assert!( - program.arms.iter().all(|arm| matches!( - &arm.partition, - distributed::graphql::ClientProjectionPartition::Unit - )), - "lobby chat uses unit partition so the chat_messages live query can advertise \ - supported index evidence (room isolation stays in the GraphQL where clause). \ - Surface-wide live_resume may still be false when owner-scoped models share the surface." - ); - } - - #[test] - fn blob_projection_owner_has_no_async_fact_route() { - let manifest = distributed_client_surface().manifest().unwrap(); - let owner = manifest - .projectors - .iter() - .find(|projector| projector.name == "project_blob") - .expect("Blob direct owner should be exported"); - assert!(owner.facts.is_empty()); - assert!(!owner.causal_confirmation); - - let repository = InMemoryRepository::new(); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository, - ); - let plan = service.subscription_plan(); - for event in [ - "todo.created", - "todo.renamed", - "todo.completed", - "todo.reopened", - "todo.archived", - "todo.force_archived", - "todo.purged", - "chat_message.posted", - ] { - assert!( - plan.events.iter().any(|candidate| candidate == event), - "eventual modeled projection must subscribe to {event}" - ); - } - for fact in [ - "blob.started", - "blob.initialized", - "blob.level_started", - "blob.moved", - ] { - assert!( - !plan.events.iter().any(|event| event == fact), - "direct-only Blob ownership must not register an async route for {fact}" - ); - } - } - - #[tokio::test] - async fn graphiql_does_not_change_the_postgres_runtime_client_manifest() { - let generated = distributed_client_surface().manifest().unwrap(); - let pool = sqlx::postgres::PgPoolOptions::new() - .connect_lazy("postgres://postgres:postgres@localhost/distributed") - .unwrap(); - let repository = distributed::PostgresRepository::new(pool.clone()); - let service = build_service( - repository.clone(), - distributed::PostgresLockManager::new(pool), - repository.clone(), - ); - let engine = - build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, true) - .expect("engine"); - let runtime = engine - .client_manifest_for_application(DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"]) - .unwrap(); - - assert_eq!(generated, runtime); - - let make_request = || { - serde_json::from_value(serde_json::json!({ - "query": "{ todos @skip(if: true) { todo_id } }", - "extensions": { - "distributed": { - "client": { - "surface": { - "kind": "application", - "name": DISTRIBUTED_CLIENT_SURFACE, - "roles": ["admin", "user"] - }, - "schemaHash": generated.schema_fingerprint - } - } - } - })) - .expect("generated application request") - }; - let mut session = distributed::microsvc::Session::new(); - session.set("x-roles", "user"); - session.set("x-user-id", "person-1"); - let response = engine.execute(&session, make_request()).await; - assert!( - !response.is_err(), - "the runtime must accept the generated application surface: {:?}", - response.errors - ); - // Multi-role admin principal may open the same portable contract. - let mut admin = session.clone(); - admin.set("x-roles", "admin,user"); - let admin_response = engine.execute(&admin, make_request()).await; - assert!( - !admin_response.is_err(), - "admin with user asserted roles must open e2e-ui: {:?}", - admin_response.errors - ); - let envelope = response - .extensions - .get("distributed") - .expect("distributed protocol envelope"); - let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); - assert_eq!( - envelope["schemaHash"], generated.schema_fingerprint, - "the authoritative response must attest the generated schema" - ); - } - - /// Empty-session open of e2e-ui-public + chat query (anonymous privilege). - /// - /// Bare protocol path for unauthenticated lobby peeks; UI route `/public` - /// documents the same surface name and extension shape. - #[tokio::test] - async fn public_surface_opens_and_queries_chat_without_identity() { - let generated = distributed_public_client_surface().manifest().unwrap(); - assert_eq!( - generated.surface, - distributed::graphql::ClientSurfaceIdentity::application( - DISTRIBUTED_PUBLIC_CLIENT_SURFACE, - ["anonymous"], - ) - ); - let repository = distributed::SqliteRepository::connect_and_migrate("sqlite::memory:") - .await - .expect("sqlite memory repo"); - let registry = e2e_readmodels::distributed_manifest() - .table_registry() - .expect("registry"); - repository - .bootstrap_table_schema_for_dev(®istry) - .await - .expect("bootstrap tables"); - let service = build_service( - repository.clone(), - ClientSurfaceLocks::default(), - repository.clone(), - ); - let engine = - build_graphql_engine_with_graphiql(&repository, &service, dev_identity(), None, false) - .expect("engine"); - let runtime = engine - .client_manifest_for_application(DISTRIBUTED_PUBLIC_CLIENT_SURFACE, &["anonymous"]) - .expect("public surface registered"); - assert_eq!(generated.schema_fingerprint, runtime.schema_fingerprint); - - let request = serde_json::from_value(serde_json::json!({ - "query": "{ chat_messages(limit: 5, offset: 0) { message_id body room_id } }", - "extensions": { - "distributed": { - "client": { - "surface": { - "kind": "application", - "name": DISTRIBUTED_PUBLIC_CLIENT_SURFACE, - "roles": ["anonymous"] - }, - "schemaHash": generated.schema_fingerprint - } - } - } - })) - .expect("public application request"); - - // No x-user-id, no x-roles — unauthenticated principal. - let session = distributed::microsvc::Session::new(); - let response = engine.execute(&session, request).await; - assert!( - !response.is_err(), - "anonymous open + chat query must succeed: {:?}", - response.errors - ); - let data = response.data.into_json().expect("json data"); - assert!( - data.get("chat_messages").and_then(|v| v.as_array()).is_some(), - "expected chat_messages array: {data}" - ); - let envelope = response - .extensions - .get("distributed") - .expect("distributed protocol envelope"); - let envelope = serde_json::to_value(envelope).expect("serialized protocol envelope"); - assert_eq!(envelope["schemaHash"], generated.schema_fingerprint); - } -} diff --git a/tests/e2e-ui/crates/suite/src/lib.rs b/tests/e2e-ui/crates/suite/src/lib.rs index 41333f72..7fe9bb55 100644 --- a/tests/e2e-ui/crates/suite/src/lib.rs +++ b/tests/e2e-ui/crates/suite/src/lib.rs @@ -123,23 +123,27 @@ pub async fn wait_ready(base: &str, timeout: Duration) -> bool { } /// Default GraphQL helper: multi-role principals must name a surface. -/// - `user` → e2e-ui (eligible admin+user, privilege user) +/// - `user` → e2e-ui (eligible admin+user, schema privilege user) /// - `admin` → e2e-ui-admin (admin privilege) pub async fn graphql(base: &str, query: &str, user_id: &str, role: &str) -> Result { - let (application, surface_roles, schema_hash) = default_application_surface(role)?; + let (application, eligible_roles, schema_roles, schema_hash) = + default_application_surface(role)?; graphql_for_application( base, query, user_id, role, application, - &surface_roles, + &eligible_roles, + &schema_roles, &schema_hash, ) .await } -fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static str>, String), String> { +fn default_application_surface( + role: &str, +) -> Result<(&'static str, Vec<&'static str>, Vec<&'static str>, String), String> { use e2e_service::{ distributed_admin_client_surface, distributed_client_surface, DISTRIBUTED_ADMIN_CLIENT_SURFACE, DISTRIBUTED_CLIENT_SURFACE, @@ -152,6 +156,7 @@ fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static Ok(( DISTRIBUTED_ADMIN_CLIENT_SURFACE, vec!["admin"], + vec!["admin"], manifest.schema_fingerprint, )) } @@ -162,6 +167,7 @@ fn default_application_surface(role: &str) -> Result<(&'static str, Vec<&'static Ok(( DISTRIBUTED_CLIENT_SURFACE, vec!["admin", "user"], + vec!["user"], manifest.schema_fingerprint, )) } @@ -174,7 +180,8 @@ pub async fn graphql_for_application( user_id: &str, role: &str, application: &str, - roles: &[&str], + eligible_roles: &[&str], + schema_roles: &[&str], schema_hash: &str, ) -> Result { graphql_request( @@ -187,7 +194,8 @@ pub async fn graphql_for_application( "surface": { "kind": "application", "name": application, - "roles": roles, + "eligible_roles": eligible_roles, + "schema_roles": schema_roles, }, "schemaHash": schema_hash, } diff --git a/tests/e2e-ui/crates/suite/tests/behavioral.rs b/tests/e2e-ui/crates/suite/tests/behavioral.rs index 8304af6b..dfaa6565 100644 --- a/tests/e2e-ui/crates/suite/tests/behavioral.rs +++ b/tests/e2e-ui/crates/suite/tests/behavioral.rs @@ -163,6 +163,7 @@ async fn t1a_application_surface_returns_actual_todo_upsert_and_causal_obligatio "user", DISTRIBUTED_CLIENT_SURFACE, &["admin", "user"], + &["user"], &manifest.schema_fingerprint, ) .await @@ -514,17 +515,18 @@ async fn t5_unauthenticated_rejected() { }} }}"# ); - // DevHeaders with no identity → mutation fails (require_user). + // DevHeaders with no identity → command guard rejects (causal_has_user). let (status, body) = graphql_raw(&base, &doc).await.expect(cases::UNAUTH); - // Prefer GraphQL errors over HTTP 401 depending on identity mode. + // GuardRejected is client-facing (often GraphQL errors / 400); Unauthorized + // remains 401 when identity is checked elsewhere. let has_err = body .get("errors") .and_then(|e| e.as_array()) .map(|a| !a.is_empty()) .unwrap_or(false); assert!( - status == 401 || has_err, - "{}: expected 401 or GraphQL errors, got HTTP {status} {body}", + status == 401 || status == 400 || has_err, + "{}: expected 401/400 or GraphQL errors, got HTTP {status} {body}", cases::UNAUTH ); eprintln!("{} ok", cases::UNAUTH); diff --git a/tests/e2e-ui/crates/todo-domain/src/lib.rs b/tests/e2e-ui/crates/todo-domain/src/lib.rs index 25499f3b..a2c61c84 100644 --- a/tests/e2e-ui/crates/todo-domain/src/lib.rs +++ b/tests/e2e-ui/crates/todo-domain/src/lib.rs @@ -9,8 +9,8 @@ pub mod models; pub use models::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoDomainIdentity, TodoError, TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, - TodoReassignedDomainEvent, TodoRenamedDomainEvent, TodoReopenedDomainEvent, TodoState, - TodoStatus, + domain_commands, Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, + TodoCreatedDomainEvent, TodoDomainIdentity, TodoError, TodoForceArchivedDomainEvent, + TodoPurgedDomainEvent, TodoReassignedDomainEvent, TodoRenamedDomainEvent, + TodoReopenedDomainEvent, TodoState, TodoStatus, }; diff --git a/tests/e2e-ui/crates/todo-domain/src/models/mod.rs b/tests/e2e-ui/crates/todo-domain/src/models/mod.rs index 357d0459..bca3dacb 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/mod.rs @@ -6,9 +6,10 @@ mod todo_state; mod todo_status; pub use todo::{ - Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, TodoCreatedDomainEvent, - TodoDomainIdentity, TodoForceArchivedDomainEvent, TodoPurgedDomainEvent, - TodoReassignedDomainEvent, TodoRenamedDomainEvent, TodoReopenedDomainEvent, + domain_commands, Todo, TodoArchivedDomainEvent, TodoCompletedDomainEvent, + TodoCreatedDomainEvent, TodoDomainIdentity, TodoForceArchivedDomainEvent, + TodoPurgedDomainEvent, TodoReassignedDomainEvent, TodoRenamedDomainEvent, + TodoReopenedDomainEvent, }; pub use todo_error::TodoError; pub use todo_state::TodoState; diff --git a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs index 6287b149..0a92fb72 100644 --- a/tests/e2e-ui/crates/todo-domain/src/models/todo.rs +++ b/tests/e2e-ui/crates/todo-domain/src/models/todo.rs @@ -252,6 +252,20 @@ mod tests { ); } + #[test] + fn domain_commands_create_matches_created_event_contract() { + use distributed::domain_event::DomainEventContract; + use distributed::graphql::CommandEventSet; + + let from_transition = domain_commands::Create::command_event_set(); + let from_event = distributed::events![TodoCreatedDomainEvent]; + assert_eq!( + from_transition, from_event, + "Create transition must materialize the same emit set as TodoCreatedDomainEvent" + ); + assert_eq!(TodoCreatedDomainEvent::EVENT_NAME, "todo.created"); + } + #[test] fn create_after_purge_is_rejected_without_emitting_events() { let mut todo = open_todo(); diff --git a/tests/e2e-ui/e2e/blob.user.spec.ts b/tests/e2e-ui/e2e/blob.user.spec.ts index 86988536..02f6571a 100644 --- a/tests/e2e-ui/e2e/blob.user.spec.ts +++ b/tests/e2e-ui/e2e/blob.user.spec.ts @@ -104,8 +104,8 @@ test.describe('blob game (alice)', () => { ); await page.keyboard.press('ArrowRight'); await moveReachedServerPromise; - // The generated Atomic preview must paint before the held GraphQL - // response is allowed back to the browser. + // Pure reduce (blob.simulate_move) paints the next board from the known + // cache row + direction before the held GraphQL response returns. await expect(board.locator('.tile-player')).toHaveAttribute( 'aria-label', 'r0 c1', diff --git a/tests/e2e-ui/e2e/todos.user.spec.ts b/tests/e2e-ui/e2e/todos.user.spec.ts index a9bfb132..efda999b 100644 --- a/tests/e2e-ui/e2e/todos.user.spec.ts +++ b/tests/e2e-ui/e2e/todos.user.spec.ts @@ -280,10 +280,16 @@ test.describe('todos (alice)', () => { .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) .locator('.item', { hasText: title }); - // Create must paint list membership optimistically under the delayed route - // (first-page offset insert + truncate). Assert before the wire returns. - await expect(openItem).toBeVisible({ timeout: 400 }); + // Auto-optimism maps title/owner/todo_id but not domain status constants + // (`open`/`completed`). Open/Done columns filter on status, so list + // membership seals with the Eventual payload rather than pre-wire paint. + // Controls must still stay enabled under the delayed route. + expect( + await page.locator('.board button:disabled').count(), + 'routine command concurrency guards must not flash Todo row controls disabled' + ).toBe(0); await createResponse; + await expect(openItem).toBeVisible({ timeout: 5_000 }); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' @@ -296,25 +302,21 @@ test.describe('todos (alice)', () => { (response) => (response.request().postData() ?? '').includes('todos_complete') ); - await openItem.getByRole('button', { name: /^done$/i }).evaluate((button) => { - button.click(); - button.click(); - }); + // Single click: without status constants in auto-optimism, the row stays + // `open` until Eventual seals, so a double-click is not client-suppressed. + await openItem.getByRole('button', { name: /^done$/i }).click(); const doneItem = page .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^done$/i }) }) .locator('.item', { hasText: title }); - await expect(doneItem).toBeVisible({ timeout: 400 }); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' ).toBe(0); - expectBinarySorted(await visibleTodoOrders(page)); await completeResponse; - expect( - completeRequests, - 'optimistic state must suppress a duplicate action without disabling controls' - ).toBe(1); + await expect(doneItem).toBeVisible({ timeout: 5_000 }); + expectBinarySorted(await visibleTodoOrders(page)); + expect(completeRequests, 'complete must reach the server once').toBe(1); await expect(doneItem).toBeVisible(); expectBinarySorted(await visibleTodoOrders(page)); await page.waitForTimeout(750); @@ -337,13 +339,14 @@ test.describe('todos (alice)', () => { .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) .locator('.item', { hasText: title }); - // Reopen must paint the row back into Open before the delayed wire returns. - await expect(reopenedItem).toBeVisible({ timeout: 400 }); + // Status-column membership seals with Eventual (no status constant in + // auto-optimism). Controls stay enabled under the delayed route. expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' ).toBe(0); await reopenResponse; + await expect(reopenedItem).toBeVisible({ timeout: 5_000 }); await page.waitForTimeout(750); const reopenOrderFrames = await stopTodoOrderTrace(page); expect( diff --git a/tests/e2e-ui/ui/distributed.clients.json b/tests/e2e-ui/ui/distributed.clients.json new file mode 100644 index 00000000..d144d40f --- /dev/null +++ b/tests/e2e-ui/ui/distributed.clients.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "clients": [ + { + "module": "$distributed", + "surface": "e2e-ui", + "documents": [ + "src/routes/blob/*/+page.graphql", + "src/routes/chat/+page.graphql", + "src/routes/todos/+page.graphql" + ], + "output": "src/lib/generated/user" + }, + { + "module": "$distributed/admin", + "surface": "e2e-ui-admin", + "documents": ["src/routes/admin/+page.graphql"], + "output": "src/lib/generated/admin", + "manifest_entrypoint": "e2e_service::distributed_admin_client_surface" + }, + { + "module": "$distributed/public", + "surface": "e2e-ui-public", + "documents": ["src/routes/chat/+page.graphql"], + "output": "src/lib/generated/public", + "manifest_entrypoint": "e2e_service::distributed_public_client_surface" + } + ] +} diff --git a/tests/e2e-ui/ui/distributed.config.js b/tests/e2e-ui/ui/distributed.config.js index 3cd48e88..04b70900 100644 --- a/tests/e2e-ui/ui/distributed.config.js +++ b/tests/e2e-ui/ui/distributed.config.js @@ -1,9 +1,264 @@ +// @ts-nocheck +// Config script for the Distributed SvelteKit plugin inventory. Checked by +// Node at load time; not part of the app type graph. `// @ts-nocheck` keeps +// `svelte-check` (checkJs) from requiring annotations on every helper. import { dirname, resolve } from 'node:path'; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync +} from 'node:fs'; import { fileURLToPath } from 'node:url'; const uiRoot = dirname(fileURLToPath(import.meta.url)); const e2eRoot = resolve(uiRoot, '..'); const distributedRoot = resolve(uiRoot, '../../..'); +const clientInventoryPath = resolve(uiRoot, 'distributed.clients.json'); + +const CLIENT_INVENTORY_SCHEMA_VERSION = 1; +const MAX_CLIENT_INVENTORY_BYTES = 1024 * 1024; +const MAX_CLIENT_JSON_DEPTH = 24; +const MAX_CLIENT_JSON_BRACKET_DEPTH = MAX_CLIENT_JSON_DEPTH + 1; +const MAX_CLIENT_STRING_BYTES = 4 * 1024; +const CLIENT_MODULE = /^\$distributed(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/; +const CLIENT_INVENTORY_KEYS = new Set(['schema_version', 'clients']); +const SECRET_LIKE = /(?:postgres(?:ql)?:\/\/|mysql:\/\/|mongodb:\/\/|bearer |password=|token=|secret=|-----begin )/i; +const textEncoder = new TextEncoder(); + +function readBoundedInventory(filePath) { + const preflight = lstatSync(filePath); + if (preflight.isSymbolicLink()) { + throw new TypeError('distributed.clients.json must not be a symlink'); + } + if (!preflight.isFile()) { + throw new TypeError('distributed.clients.json must be a regular file'); + } + if (preflight.size > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + + let descriptor; + try { + const noFollow = constants.O_NOFOLLOW ?? 0; + descriptor = openSync(filePath, constants.O_RDONLY | noFollow); + } catch (error) { + if (error?.code === 'ELOOP') { + throw new TypeError('distributed.clients.json must not be a symlink'); + } + throw error; + } + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile()) { + throw new TypeError('distributed.clients.json must be a regular file'); + } + if (metadata.size > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + + const buffer = Buffer.allocUnsafe(MAX_CLIENT_INVENTORY_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_CLIENT_INVENTORY_BYTES) { + throw new TypeError( + `distributed.clients.json exceeds maximum size ${MAX_CLIENT_INVENTORY_BYTES} bytes` + ); + } + return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, offset)); + } finally { + closeSync(descriptor); + } +} + +function assertJsonNestingDepth(source) { + let depth = 0; + let inString = false; + let escaped = false; + for (const character of source) { + if (inString) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + } else if (character === '{' || character === '[') { + depth += 1; + if (depth > MAX_CLIENT_JSON_BRACKET_DEPTH) { + throw new TypeError( + `distributed.clients.json exceeds maximum JSON nesting depth ${MAX_CLIENT_JSON_DEPTH}` + ); + } + } else if (character === '}' || character === ']') { + depth -= 1; + } + } +} + +function assertPortablePath(value, label, { allowGlob = false } = {}) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + textEncoder.encode(value).length > MAX_CLIENT_STRING_BYTES || + value.includes('\0') || + value.includes('\\') || + SECRET_LIKE.test(value) || + value.startsWith('/') || + value.startsWith('~') || + textEncoder.encode(value)[1] === 0x3a || + value.split('/').some((part) => part === '..' || part === '.') || + (!allowGlob && /[*?\[\]{]/.test(value)) + ) { + throw new TypeError(`${label} must be a portable repository-relative path`); + } +} + +function assertClientIdentifier(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + textEncoder.encode(value).length > MAX_CLIENT_STRING_BYTES || + value.includes('..') || + SECRET_LIKE.test(value) + ) { + throw new TypeError(`${label} must be a portable identifier`); + } +} + +export function validateClientInventory(value) { + if ( + value === null || + typeof value !== 'object' || + value.schema_version !== CLIENT_INVENTORY_SCHEMA_VERSION || + !Array.isArray(value.clients) || + value.clients.length === 0 || + value.clients.length > 64 + ) { + throw new TypeError( + `distributed.clients.json must be schema version ${CLIENT_INVENTORY_SCHEMA_VERSION} with 1..=64 clients` + ); + } + for (const key of Object.keys(value)) { + if (!CLIENT_INVENTORY_KEYS.has(key)) { + throw new TypeError(`distributed.clients.json contains unsupported field ${key}`); + } + } + const modules = new Set(); + const surfaces = new Set(); + const outputs = new Set(); + const allowedKeys = new Set([ + 'module', + 'surface', + 'documents', + 'output', + 'manifest_entrypoint' + ]); + return Object.freeze( + value.clients.map((client, index) => { + if (client === null || typeof client !== 'object') { + throw new TypeError(`distributed client declaration ${index} must be an object`); + } + for (const key of Object.keys(client)) { + if (!allowedKeys.has(key)) { + throw new TypeError(`distributed client ${index} contains unsupported field ${key}`); + } + } + if ( + typeof client.module !== 'string' || + !CLIENT_MODULE.test(client.module) || + client.module.includes('..') || + textEncoder.encode(client.module).length > MAX_CLIENT_STRING_BYTES || + SECRET_LIKE.test(client.module) + ) { + throw new TypeError(`distributed client ${index} has an invalid module`); + } + if ( + typeof client.surface !== 'string' || + !/^[A-Za-z0-9._:/-]+$/.test(client.surface) || + client.surface.trim().length === 0 + ) { + throw new TypeError(`distributed client ${client.module} has an invalid surface`); + } + assertClientIdentifier(client.surface, `${client.module} surface`); + if (!Array.isArray(client.documents) || client.documents.length === 0 || client.documents.length > 64) { + throw new TypeError(`distributed client ${client.module} must declare 1..=64 documents`); + } + const documents = client.documents.map((document, documentIndex) => { + assertPortablePath(document, `${client.module} documents[${documentIndex}]`, { allowGlob: true }); + if (!document.endsWith('.graphql') && !document.endsWith('.gql')) { + throw new TypeError(`${client.module} documents[${documentIndex}] must end in .graphql or .gql`); + } + if (document.includes('**') || /^[*?\[\]{]/.test(document)) { + throw new TypeError(`${client.module} document glob is unbounded`); + } + return document; + }); + if (new Set(documents).size !== documents.length) { + throw new TypeError(`distributed client ${client.module} contains duplicate documents`); + } + assertPortablePath(client.output, `${client.module} output`); + const manifestEntrypoint = client.manifest_entrypoint ?? undefined; + if (typeof manifestEntrypoint === 'string') { + if ( + manifestEntrypoint.length === 0 || + textEncoder.encode(manifestEntrypoint).length > MAX_CLIENT_STRING_BYTES || + manifestEntrypoint.split('::').some( + (segment) => !/^[A-Za-z0-9_]+$/.test(segment) + ) + ) { + throw new TypeError(`${client.module} manifest_entrypoint is invalid`); + } + } else if (client.manifest_entrypoint !== undefined && client.manifest_entrypoint !== null) { + throw new TypeError(`${client.module} manifest_entrypoint must be a string`); + } + if (modules.has(client.module)) throw new TypeError(`duplicate client module ${client.module}`); + if (surfaces.has(client.surface)) throw new TypeError(`duplicate client surface ${client.surface}`); + if (outputs.has(client.output)) throw new TypeError(`duplicate client output ${client.output}`); + modules.add(client.module); + surfaces.add(client.surface); + outputs.add(client.output); + return Object.freeze({ + module: client.module, + surface: client.surface, + documents: Object.freeze(documents), + output: client.output, + manifest_entrypoint: manifestEntrypoint + }); + }) + ); +} + +export function loadClientInventory(filePath = clientInventoryPath) { + const source = readBoundedInventory(filePath); + assertJsonNestingDepth(source); + let value; + try { + value = JSON.parse(source); + } catch { + throw new TypeError('distributed.clients.json is invalid JSON; check its syntax'); + } + return validateClientInventory(value); +} + +const clientDeclarations = loadClientInventory(); const manifestArgs = [ 'client-manifest', @@ -14,47 +269,25 @@ const manifestArgs = [ '--distributed-path', distributedRoot ]; - -/** App-owned service/surface configuration; all generated behavior is package-owned. */ -export const distributedClients = Object.freeze([ - Object.freeze({ - module: '$distributed', - manifest: Object.freeze({ args: Object.freeze(manifestArgs) }), - surface: 'e2e-ui', - documents: Object.freeze([ - 'src/routes/todos/+page.graphql', - 'src/routes/chat/+page.graphql', - 'src/routes/blob/*/+page.graphql' - ]), - out: 'src/lib/generated/user' - }), - Object.freeze({ - module: '$distributed/admin', - manifest: Object.freeze({ - args: Object.freeze([ - ...manifestArgs, - '--entrypoint', - 'e2e_service::distributed_admin_client_surface' - ]) - }), - surface: 'e2e-ui-admin', - documents: Object.freeze(['src/routes/admin/+page.graphql']), - out: 'src/lib/generated/admin' - }), - Object.freeze({ - module: '$distributed/public', - manifest: Object.freeze({ - args: Object.freeze([ - ...manifestArgs, - '--entrypoint', - 'e2e_service::distributed_public_client_surface' - ]) - }), - surface: 'e2e-ui-public', - documents: Object.freeze(['src/routes/chat/+page.graphql']), - out: 'src/lib/generated/public' - }) -]); +/** App-owned declarations come from distributed.clients.json; this file adds executable and local paths. */ +export const distributedClients = Object.freeze( + clientDeclarations.map((client) => + Object.freeze({ + module: client.module, + manifest: Object.freeze({ + args: Object.freeze([ + ...manifestArgs, + ...(client.manifest_entrypoint === undefined + ? [] + : ['--entrypoint', client.manifest_entrypoint]) + ]) + }), + surface: client.surface, + documents: client.documents, + out: client.output + }) + ) +); export const distributedViteOptions = Object.freeze({ cwd: uiRoot, @@ -67,7 +300,7 @@ export const distributedViteOptions = Object.freeze({ '-p', 'distributed_cli', '--bin', - 'dctl', + 'distributed', '--' ]), clients: distributedClients diff --git a/tests/e2e-ui/ui/scripts/distributed-client.mjs b/tests/e2e-ui/ui/scripts/distributed-client.mjs index 87783da5..e7cd2b9a 100644 --- a/tests/e2e-ui/ui/scripts/distributed-client.mjs +++ b/tests/e2e-ui/ui/scripts/distributed-client.mjs @@ -8,10 +8,10 @@ import { distributedViteOptions } from '../distributed.config.js'; const mode = process.argv[2]; if (mode === 'generate') { await generateDistributedSvelteKit(distributedViteOptions); - console.log('Generated Distributed user/admin clients from distributed.config.js'); + console.log('Generated Distributed clients from distributed.clients.json'); } else if (mode === 'check') { await checkDistributedSvelteKit(distributedViteOptions); - console.log('Distributed user/admin clients are current'); + console.log('Distributed clients are current'); } else { throw new Error('usage: node scripts/distributed-client.mjs '); } diff --git a/tests/e2e-ui/ui/src/lib/blob/board.ts b/tests/e2e-ui/ui/src/lib/blob/board.ts new file mode 100644 index 00000000..1aa0995d --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/blob/board.ts @@ -0,0 +1,36 @@ +/** + * Board JSON helpers for rendering query data. Not game rules — the domain + * owns move outcomes on the server. + */ + +export const TILE = { + hole: 0, + unvisited: 1, + visited: 2, + deadBySuicide: 3, + deadByHole: 4, + player: 9 +} as const; + +export type Direction = 'up' | 'down' | 'left' | 'right'; + +export class BoardParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'BoardParseError'; + } +} + +/** Parse a `map_json` board from the read model. */ +export function parseBoard(mapJson: string): number[][] { + const value = JSON.parse(mapJson || '[]') as unknown; + if ( + !Array.isArray(value) || + !value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') + ) + ) { + throw new BoardParseError('invalid map_json'); + } + return value as number[][]; +} diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts index a590c2b2..46a3cc10 100644 --- a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts +++ b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts @@ -1,146 +1,140 @@ /** - * Pure move rules — byte-identical twin of `blob_domain::simulate_move`. + * Pure post-move board snapshot — TypeScript twin of + * `blob_domain::simulate_move`. Must stay byte-identical for tile rules so + * auto-optimism paints the same board the Atomic response will seal. * - * Used only to fill command **input** fields for the same `.applies` / - * optimistic-layer path as chat/todos (not a page-local board overlay). - * The server still recomputes authoritatively from `game_id` + `direction`. + * Registered as pure function `blob.simulate_move` on the command runtime. */ -export const TILE = { - hole: 0, - unvisited: 1, - visited: 2, - deadBySuicide: 3, - deadByHole: 4, - player: 9 -} as const; +const HOLE = 0; +const UNVISITED = 1; +const VISITED = 2; +const DEAD_BY_SUICIDE = 3; +const DEAD_BY_HOLE = 4; +const PLAYER = 9; -export type Direction = 'up' | 'down' | 'left' | 'right'; +export type BlobMoveArgs = Readonly<{ + direction: string; +}>; -export type MovePreview = { - readonly map: number[][]; - readonly score: number; - readonly player_dead: boolean; - readonly level_complete: boolean; - readonly status: string; - readonly map_json: string; -}; - -export class SimulateMoveError extends Error { - constructor(message: string) { - super(message); - this.name = 'SimulateMoveError'; - } -} - -function statusOf(playerDead: boolean, levelComplete: boolean): string { - if (playerDead) return 'dead'; - if (levelComplete) return 'level_complete'; - return 'active'; -} - -function playerPos(map: number[][]): { r: number; c: number } { - for (let r = 0; r < map.length; r += 1) { - const row = map[r]!; - for (let c = 0; c < row.length; c += 1) { - if (row[c] === TILE.player) return { r, c }; - } - } - throw new SimulateMoveError('no active level'); -} +export type BlobMoveResult = Readonly<{ + map_json: string; + score: number; + player_dead: boolean; + current_level_completed: boolean; + status: string; +}>; /** - * Apply one direction to a map + score. Mirrors `blob_domain::simulate_move`. + * Apply one direction to a known BlobGames row. + * Returns null when the move is impossible (edge/no map) so optimism fails closed. */ export function simulateMove( - map: number[][], - score: number, - direction: Direction -): MovePreview { - if (map.length === 0 || (map[0]?.length ?? 0) === 0) { - throw new SimulateMoveError('no active level'); + record: Readonly>, + args: Readonly> +): BlobMoveResult | null { + const direction = args.direction; + if (typeof direction !== 'string') return null; + const mapJson = record.map_json; + if (typeof mapJson !== 'string') return null; + let map: number[][]; + try { + map = JSON.parse(mapJson) as number[][]; + } catch { + return null; } - const { r, c } = playerPos(map); - let nr: number; - let nc: number; - switch (direction) { - case 'up': - if (r === 0) throw new SimulateMoveError('row already 0'); - nr = r - 1; - nc = c; - break; - case 'down': - if (r + 1 >= map.length) throw new SimulateMoveError('already at bottom edge'); - nr = r + 1; - nc = c; - break; - case 'left': - if (c === 0) throw new SimulateMoveError('column already 0'); - nr = r; - nc = c - 1; - break; - case 'right': - if (c + 1 >= (map[r]?.length ?? 0)) { - throw new SimulateMoveError('already at right edge'); - } - nr = r; - nc = c + 1; - break; - default: { - const _exhaustive: never = direction; - throw new SimulateMoveError(`invalid direction: ${_exhaustive}`); - } + if (!Array.isArray(map) || map.length === 0 || !Array.isArray(map[0]) || map[0]!.length === 0) { + return null; } + const scoreRaw = record.score; + const score = + typeof scoreRaw === 'number' + ? scoreRaw + : typeof scoreRaw === 'bigint' + ? Number(scoreRaw) + : typeof scoreRaw === 'string' + ? Number(scoreRaw) + : NaN; + if (!Number.isFinite(score)) return null; - const nextMap = map.map((row) => [...row]); + const pos = playerPos(map); + if (pos === null) return null; + const [r, c] = pos; + const next = step(r, c, direction, map); + if (next === null) return null; + const [nr, nc] = next; + + const nextMap = map.map((row) => row.slice()); let nextScore = score; let playerDead = false; let levelComplete = false; - nextMap[r]![c] = TILE.visited; - const landing = nextMap[nr]![nc]!; - if (landing === TILE.hole) { - nextMap[nr]![nc] = TILE.deadByHole; - } else if (landing === TILE.visited) { - nextMap[nr]![nc] = TILE.deadBySuicide; - } else if (landing === TILE.unvisited || landing === TILE.player) { + nextMap[r]![c] = VISITED; + const target = nextMap[nr]![nc]!; + if (target === HOLE) { + nextMap[nr]![nc] = DEAD_BY_HOLE; + } else if (target === VISITED) { + nextMap[nr]![nc] = DEAD_BY_SUICIDE; + } else if (target === UNVISITED || target === PLAYER) { nextScore += 1; - nextMap[nr]![nc] = TILE.player; + nextMap[nr]![nc] = PLAYER; } else { - nextMap[nr]![nc] = TILE.deadBySuicide; + nextMap[nr]![nc] = DEAD_BY_SUICIDE; } for (const row of nextMap) { - if (row.includes(TILE.deadByHole) || row.includes(TILE.deadBySuicide)) { + if (row.includes(DEAD_BY_HOLE) || row.includes(DEAD_BY_SUICIDE)) { playerDead = true; levelComplete = false; break; } } if (!playerDead) { - levelComplete = !nextMap.some((row) => row.includes(TILE.unvisited)); + levelComplete = !nextMap.some((row) => row.includes(UNVISITED)); } return Object.freeze({ - map: nextMap, + map_json: JSON.stringify(nextMap), score: nextScore, player_dead: playerDead, - level_complete: levelComplete, - status: statusOf(playerDead, levelComplete), - map_json: JSON.stringify(nextMap) + current_level_completed: levelComplete, + status: playerDead ? 'dead' : levelComplete ? 'level_complete' : 'active' }); } -/** Parse a `map_json` board; throws if shape is not `number[][]`. */ -export function parseBoard(mapJson: string): number[][] { - const value = JSON.parse(mapJson || '[]') as unknown; - if ( - !Array.isArray(value) || - !value.every( - (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') - ) - ) { - throw new SimulateMoveError('invalid map_json'); +function playerPos(map: number[][]): [number, number] | null { + for (let r = 0; r < map.length; r += 1) { + const row = map[r]!; + for (let c = 0; c < row.length; c += 1) { + if (row[c] === PLAYER) return [r, c]; + } } - return value as number[][]; + return null; } + +function step( + r: number, + c: number, + direction: string, + map: number[][] +): [number, number] | null { + switch (direction) { + case 'up': + return r === 0 ? null : [r - 1, c]; + case 'down': + return r + 1 >= map.length ? null : [r + 1, c]; + case 'left': + return c === 0 ? null : [r, c - 1]; + case 'right': { + const row = map[r]!; + return c + 1 >= row.length ? null : [r, c + 1]; + } + default: + return null; + } +} + +/** Pure registry entry for the command runtime. */ +export const BLOB_PURE_FUNCTIONS = Object.freeze({ + 'blob.simulate_move': simulateMove +}); diff --git a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte index f675cd32..68a6d86d 100644 --- a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte +++ b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte @@ -14,6 +14,8 @@ let { demo, defaultOpen = false }: Props = $props(); + // Intentionally seed once from the prop (deep-link); toggles use `open` only. + // svelte-ignore state_referenced_locally let open = $state(defaultOpen); let activeTab = $state(0); @@ -51,7 +53,7 @@ {/if} -

+