diff --git a/docs/explanation/architecture-resource-roadmap.md b/docs/explanation/architecture-resource-roadmap.md index d5eade1e..1917134a 100644 --- a/docs/explanation/architecture-resource-roadmap.md +++ b/docs/explanation/architecture-resource-roadmap.md @@ -43,19 +43,19 @@ versions, reference discovery, guarded mutations and branch behavior. Runtime evidence comes from the linked executable tests. Component review coverage does not establish deployed-browser or production readiness. -## Next resource slice: Specification and API contracts +## Implemented Specification and API resources The [Specification/API contract](../reference/specification-and-api-contracts.md) -defines the next native resource slice. Its schema and runtime implementation -are pending. The [acceptance fixture](../reference/specification-and-api-acceptance.md) -records required queries, identity changes, authorization and lifecycle cases; -it does not claim executable evidence. - -Implementation must add the entity/member representations, identified -associations, authorized admission, exact reference discovery and lifecycle -handling together. Native manifest, CLI/MCP and Studio support must preserve -those selections. No source connector or additional format compiler is required -by this slice. +defines native retained artifacts, selectable members and identified +associations. Backend admission and exact reference resolution preserve separate +request/response mappings and explicit provider usage. Native manifest, CLI/MCP +and Studio review surfaces preserve these selections. The +[acceptance reference](../reference/specification-and-api-acceptance.md) links +the executable fixture and identity, authorization and lifecycle regressions. + +Retained opaque formats and selectors do not imply semantic format validation, +a source connector or another format compiler. Deployment and production-scale +evidence remain distinct from the linked local tests. ## Follow-on contracts diff --git a/docs/explanation/wire-compatibility.md b/docs/explanation/wire-compatibility.md index 117cf785..c07ef826 100644 --- a/docs/explanation/wire-compatibility.md +++ b/docs/explanation/wire-compatibility.md @@ -116,12 +116,19 @@ retired for that stored entity incarnation, and connection references also pin the owner's system UID so delete and recreate cannot revive a stale reference. Contract revision 10 adds Channel and DataStore entity kinds with identified -associations and reference admission. Both mutation compatibility floors are -revision 10: clients and servers must understand these owners and preserve +associations and reference admission. That revision raised both mutation +compatibility floors to 10: clients and servers must understand these owners and preserve their association IDs and pinned observation references. Upgrade the server and clients together before authoring these resources. Existing revision-9 entities retain their stored identities. +The current contract revision is 11, with both mutation compatibility floors +also at 11. It adds Specification members, exact member/content pins and +identified API associations. Writers must preserve member IDs as well as +connection IDs, request/response roles and provider selections. Upgrade clients +and server before authoring these resources; an exact entity version alone +does not pin a mutable contract's content or member incarnation. + ## Supported combinations | Client vs server | Mutations | diff --git a/docs/reference/channel-and-datastore-contracts.md b/docs/reference/channel-and-datastore-contracts.md index e66c1da9..3f4d0ac9 100644 --- a/docs/reference/channel-and-datastore-contracts.md +++ b/docs/reference/channel-and-datastore-contracts.md @@ -251,8 +251,8 @@ policy enforcement and credential management remain outside Atlas execution. ## Reference scan budget -Resource reference and impact scans use an independent budget of 5,000 Channel -and DataStore rows, combined, across the caller's visible namespaces. They +Resource reference and impact scans use an independent budget of 5,000 Channel, +DataStore and Specification rows, combined, across the caller's visible namespaces. They supplement capped model projections so a missing projection row cannot silently remove an inbound architecture dependency. Exceeding this visible scan budget returns `RESOURCE_EXHAUSTED`. diff --git a/docs/reference/connection-identity.md b/docs/reference/connection-identity.md index 468e26bb..5046dc1c 100644 --- a/docs/reference/connection-identity.md +++ b/docs/reference/connection-identity.md @@ -23,11 +23,21 @@ The identity fields are: | `Swimlane.Transition.Next` | `connection_id` | | `UI.Transition` | `connection_id` | | `ChannelBinding`, `ReadModelAttachment` | `connection_id` | +| `SchemaDefinition`, `SchemaBinding`, `MessageReadModelMapping` | `connection_id` | +| `OperationMessage`, `ChannelOperationBinding` | `connection_id` | +| `ProviderExposure`, `ApiUsage`, `ContractDependency` | `connection_id` | The typed edges include slice endpoints and storyboard entry/outcome endpoints. Their ownership is the containing entity, not the referenced primitive. A local ID does not introduce a new top-level entity or another behavioral primitive. +Specification associations belong to the containing Specification incarnation, +including associations nested in members. Their endpoint identity includes the +containing member and declared role or selection. Selectable members have +separate `ContractMemberId` values and use `ContractMemberRef`; they are not +connections. The [Specification contract](specification-and-api-contracts.md) +defines member restoration, immutable content pins and local selection rules. + `ConnectionRef` contains `owner: EntityRef`, `owner_uid` and `id: ConnectionId`. The core resolver checks the exact owner kind/version, incarnation and unique stored occurrence. Missing, duplicate, malformed or removed selections do not diff --git a/docs/reference/specification-and-api-acceptance.md b/docs/reference/specification-and-api-acceptance.md index bc5645f1..82e5a30b 100644 --- a/docs/reference/specification-and-api-acceptance.md +++ b/docs/reference/specification-and-api-acceptance.md @@ -1,9 +1,9 @@ # Specification and API acceptance fixture -Status: required acceptance criteria for the -[Specification/API contract](specification-and-api-contracts.md). The resource -schema, service implementation, CLI/MCP support and Studio surfaces are pending. -This fixture describes required outcomes; it is not executable test evidence. +Status: implemented native resource slice. This reference records acceptance +criteria for the [Specification/API contract](specification-and-api-contracts.md) +and links executable evidence below. Local service, store, authoring and +component tests do not establish production or deployed-browser readiness. ## Connected fixture @@ -114,14 +114,45 @@ preview. Invalid or forbidden target diagnostics must not expose hidden data. | Exceed the visible resource reporting budget during guarded deletion | Reporting may be incomplete under its explicit policy; authorization must still use the complete incoming-reference guard. | | Encounter a complete-guard scan error or timeout | Refuse deletion. An incomplete scan is not proof that a target is unreferenced. | -## Implementation evidence required +## Executable coverage and limits -The implementation PR must replace this pending status with links to executable -public-service regressions, real store reference-guard tests, native manifest -round trips, CLI/MCP coverage and Studio review coverage. Assert absence of -invented provider cross-products and behavioral edges, not just the presence of -the intended references. +- The [connected public-service fixture](../../rsworkspace/crates/trogon-atlas-server/tests/specification_fixture.rs) + creates the behavioral graph, Channel, native Schema, TypeLibrary and pinned + external schema. It checks independent roles and ReadModels, selective provider + usage, nested references, absence of invented behavioral edges and exact native + manifest export. +- [Backend regressions](../../rsworkspace/crates/trogon-atlas-server/tests/specification_resources.rs) + exercise atomic admission, immutable content, exact member/type pins, branch + tombstones, permitted unresolved declarations, selected-library compilation, + out-of-projection anchors and authenticated hidden-referrer protection through + real gRPC authorization. Existing TypeLibrary compatibility rules still guard + breaking edits; forced deletion exercises retained unresolved diagnostics. +- [Core identity regressions](../../rsworkspace/crates/trogon-atlas-core/tests/specification.rs) + cover member/connection identity, no-op manifests, concurrent removal and + explicit restoration, cloning, local reference repair, immutable byte pins, + selector presence, value limits and retargeting. Malformed or ambiguous + persisted member identity sets fail resolution. +- [Store reference guards](../../rsworkspace/crates/trogon-atlas-store/tests/reference_guards.rs) + include nested Specification targets in complete multi-target scans and merged + branch tombstone behavior. +- [CLI round trips](../../rsworkspace/crates/trogon-atlas-cli/tests/export_cli_integration.rs) + and [MCP transport round trips](../../rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs) + preserve authoritative member/association IDs, CRLF source bytes, digests and + unchanged reapply identity through native authoring surfaces. +- [Studio review tests](../../tsworkspace/webapps/studio/src/components/SpecificationDetails.test.tsx) + cover declared API classification, member/pin display, role separation, + provider selection and explicitly unverified opaque selectors. The production + [protobuf-loading regression](../../tsworkspace/webapps/studio/server/devops-contract.test.mjs) + checks the new kind and retained values through the actual service-package + codec. [Validation-context tests](../../tsworkspace/webapps/studio/src/lib/validation-context.test.ts) + cover nested typed reference discovery without treating artifact content or + arbitrary annotation payloads as reference declarations. -Documentation checks establish link and formatting integrity only. They do not -establish a generated wire contract, backend enforcement, format compatibility, -runtime behavior or deployed-browser readiness. +The protobuf string representation accepts UTF-8 artifact text; binary codecs +and semantic parsing of retained formats are outside this release. Opaque +selectors remain unverified. Native TypeLibrary compilation does not establish +compatibility of those opaque documents. + +Documentation checks establish formatting and link integrity. Executable local +tests establish the behaviors they exercise, not deployed UI operation, +production scale or a complete proof of every possible concurrency schedule. diff --git a/docs/reference/specification-and-api-contracts.md b/docs/reference/specification-and-api-contracts.md index 987a9983..f36c03e3 100644 --- a/docs/reference/specification-and-api-contracts.md +++ b/docs/reference/specification-and-api-contracts.md @@ -1,10 +1,9 @@ # Specification and API contracts -Status: native first-release contract; schema, backend, CLI/MCP and Studio -implementation is pending. Names below describe the required model, not -existing protobuf messages or allocated field numbers. The -[acceptance contract](specification-and-api-acceptance.md) defines the connected -fixture and failure cases required before implementation can be called complete. +Status: native schema, backend, CLI/MCP and Studio review support are +implemented. The [protobuf contract](../../proto/trogonatlas/eventmodel/v1alpha1/specification.proto) +defines the wire representation. The [acceptance reference](specification-and-api-acceptance.md) +links executable coverage and distinguishes it from deployment evidence. ## Behavioral scope diff --git a/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto b/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto index 62aa7c48..5d160d9c 100644 --- a/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto +++ b/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto @@ -79,6 +79,7 @@ import "trogonatlas/type/v1alpha1/system_meta.proto"; // for SystemMeta import "trogonatlas/eventmodel/v1alpha1/reliability.proto"; import "trogonatlas/eventmodel/v1alpha1/type_library.proto"; import "trogonatlas/eventmodel/v1alpha1/architecture.proto"; +import "trogonatlas/eventmodel/v1alpha1/specification.proto"; // ============================================================================= // Event Modeling schema. @@ -601,6 +602,7 @@ message Entity { TypeLibrary type_library = 30; Channel channel = 31; DataStore data_store = 32; + Specification specification = 33; } trogonatlas.type.v1alpha1.SystemMeta system = 29; } diff --git a/proto/trogonatlas/eventmodel/v1alpha1/identity.proto b/proto/trogonatlas/eventmodel/v1alpha1/identity.proto index 4729f505..58bc625c 100644 --- a/proto/trogonatlas/eventmodel/v1alpha1/identity.proto +++ b/proto/trogonatlas/eventmodel/v1alpha1/identity.proto @@ -12,6 +12,9 @@ import "trogonatlas/type/v1alpha1/id.proto"; // EventExample/CommandExample/ReadModelExample, inside TypeRef, etc.). // Where a connection between entities can carry data (slice endpoints, // storyboard entry/outcome), the schema uses Edge messages instead. +message SpecificationRef { trogonatlas.type.v1alpha1.Id id = 1; } +message SchemaRef { trogonatlas.type.v1alpha1.Id id = 1; } + message ChannelRef { trogonatlas.type.v1alpha1.Id id = 1; } message DataStoreRef { trogonatlas.type.v1alpha1.Id id = 1; } @@ -88,4 +91,5 @@ enum EntityKind { ENTITY_KIND_TYPE_LIBRARY = 29; ENTITY_KIND_CHANNEL = 30; ENTITY_KIND_DATA_STORE = 31; + ENTITY_KIND_SPECIFICATION = 32; } diff --git a/proto/trogonatlas/eventmodel/v1alpha1/specification.proto b/proto/trogonatlas/eventmodel/v1alpha1/specification.proto new file mode 100644 index 00000000..60659c12 --- /dev/null +++ b/proto/trogonatlas/eventmodel/v1alpha1/specification.proto @@ -0,0 +1,232 @@ +syntax = "proto3"; + +package trogonatlas.eventmodel.v1alpha1; + +import "google/protobuf/any.proto"; +import "trogonatlas/type/v1alpha1/id.proto"; +import "trogonatlas/type/v1alpha1/connection_id.proto"; +import "trogonatlas/eventmodel/v1alpha1/identity.proto"; +import "trogonatlas/eventmodel/v1alpha1/type_library.proto"; +import "trogonatlas/eventmodel/v1alpha1/architecture.proto"; +import "trogonatlas/eventmodel/v1alpha1/reliability.proto"; + +message ContractMemberId { string value = 1; } +message MemberName { string value = 1; } +message ContentDigest { string sha256 = 1; } +message ArtifactPath { string value = 1; } +message MediaType { string value = 1; } +message FormatVersion { string value = 1; } +message QualifiedProtoName { string value = 1; } + +message ContractFormat { + enum Known { + KNOWN_UNSPECIFIED = 0; + KNOWN_OPENAPI = 1; + KNOWN_ASYNCAPI = 2; + KNOWN_GRAPHQL = 3; + KNOWN_JSON_SCHEMA = 4; + KNOWN_AVRO = 5; + KNOWN_PROTOBUF = 6; + } + oneof kind { + Known known = 1; + string other = 2; + } +} + +message OpaqueTypeSelector { + string syntax = 1; + string value = 2; +} + +enum ContractMemberKind { + CONTRACT_MEMBER_KIND_UNSPECIFIED = 0; + CONTRACT_MEMBER_KIND_ARTIFACT = 1; + CONTRACT_MEMBER_KIND_SCHEMA = 2; + CONTRACT_MEMBER_KIND_MESSAGE = 3; + CONTRACT_MEMBER_KIND_OPERATION = 4; +} + +message ContractMemberRef { + SpecificationRef specification = 1; + string owner_uid = 2; + ContractMemberKind kind = 3; + ContractMemberId member_id = 4; + ContentDigest expected_digest = 5; +} + +message ContractTypeRef { + ContractMemberRef schema = 1; + ContractMemberId expected_artifact_id = 2; + ContentDigest expected_digest = 3; + OpaqueTypeSelector expected_selector = 4; +} + +message ContractArtifact { + ContractMemberId member_id = 1; + MemberName name = 2; + optional string doc = 3; + ArtifactPath path = 4; + ContractFormat format = 5; + FormatVersion format_version = 6; + MediaType media_type = 7; + string content = 8; + ContentDigest digest = 9; +} + +message LocalArtifactSelection { + ContractMemberId artifact_id = 1; + ContentDigest expected_digest = 2; +} + +message SchemaDefinition { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + oneof target { + LocalArtifactSelection artifact = 2; + SchemaRef schema = 3; + } + OpaqueTypeSelector selector = 4; +} + +message ContractSchema { + ContractMemberId member_id = 1; + MemberName name = 2; + optional string doc = 3; + SchemaDefinition definition = 4; +} + +message TypeLibraryTypeRef { + TypeLibraryRef library = 1; + QualifiedProtoName message = 2; +} + +message SchemaBinding { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_PAYLOAD = 1; + ROLE_HEADERS = 2; + } + Role role = 2; + oneof type { + ContractMemberId local_schema = 3; + ContractTypeRef contract_type = 4; + SchemaRef schema = 5; + TypeLibraryTypeRef library_type = 6; + } + optional string doc = 7; +} + +message MessageReadModelMapping { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ReadModelRef read_model = 2; +} + +message ContractMessage { + ContractMemberId member_id = 1; + MemberName name = 2; + optional string doc = 3; + repeated SchemaBinding schemas = 4; + MessageReadModelMapping mapping = 5; +} + +message OperationMessage { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ContractMemberId message_id = 2; + enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_REQUEST = 1; + ROLE_RESPONSE = 2; + ROLE_NOTIFICATION = 3; + } + Role role = 3; + optional string doc = 4; +} + +message HttpOperationBinding { + string method = 1; + string path = 2; + repeated uint32 response_status_codes = 3; +} +message RpcOperationBinding { + string service = 1; + string method = 2; +} +message ChannelOperationBinding { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ChannelRef channel = 2; +} +message GraphqlOperationBinding { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_QUERY = 1; + KIND_MUTATION = 2; + KIND_SUBSCRIPTION = 3; + } + Kind kind = 1; + string field = 2; +} +message OtherOperationBinding { + ProtocolName protocol = 1; + string description = 2; +} +message OperationBinding { + oneof kind { + HttpOperationBinding http = 1; + RpcOperationBinding rpc = 2; + ChannelOperationBinding channel = 3; + GraphqlOperationBinding graphql = 4; + OtherOperationBinding other = 5; + } +} + +message ContractOperation { + ContractMemberId member_id = 1; + MemberName name = 2; + optional string doc = 3; + OperationBinding binding = 4; + repeated OperationMessage messages = 5; +} + +message ProviderTarget { + oneof target { + ComponentRef component = 1; + ExternalSystemRef external_system = 2; + } +} +message ProviderExposure { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ProviderTarget provider = 2; + ContractMemberId operation_id = 3; + optional string doc = 4; +} +message ApiUsage { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ProviderTarget caller = 2; + ContractMemberId operation_id = 3; + trogonatlas.type.v1alpha1.ConnectionId exposure_id = 4; + optional string doc = 5; +} +message ContractDependency { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + oneof target { + SpecificationRef specification = 2; + TypeLibraryRef type_library = 3; + } + optional string doc = 4; +} + +message Specification { + trogonatlas.type.v1alpha1.Id id = 1; + string title = 2; + optional string doc = 3; + repeated google.protobuf.Any metadata = 4; + trogonatlas.type.v1alpha1.Id supersedes = 5; + repeated ContractArtifact artifacts = 6; + repeated ContractSchema schemas = 7; + repeated ContractMessage messages = 8; + repeated ContractOperation operations = 9; + repeated ProviderExposure exposures = 10; + repeated ApiUsage usages = 11; + repeated ContractDependency dependencies = 12; +} diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 3f57a306..c1795c18 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -1542,7 +1542,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots 1.0.10", ] [[package]] @@ -2957,7 +2957,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots 1.0.10", ] [[package]] @@ -3154,9 +3154,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4847,14 +4847,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/export_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/export_cli_integration.rs index 981c2b08..03cca636 100644 --- a/rsworkspace/crates/trogon-atlas-cli/tests/export_cli_integration.rs +++ b/rsworkspace/crates/trogon-atlas-cli/tests/export_cli_integration.rs @@ -959,3 +959,156 @@ spec: assert!(!reapplied.stdout.contains("created"), "{reapplied:?}"); assert!(!reapplied.stdout.contains("updated"), "{reapplied:?}"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn specification_cli_roundtrip_preserves_stored_members_associations_and_artifact_bytes() { + let endpoint = start_server().await; + let ns = "cli-specification"; + let directory = tempfile_dir(); + let anchors = directory.path().join("anchors.yaml"); + std::fs::write( + &anchors, + format!( + r" +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ReadModel +metadata: {{namespace: {ns}, name: receipt, version: 2}} +spec: {{title: Receipt}} +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Component +metadata: {{namespace: {ns}, name: gateway}} +spec: {{title: Gateway}} +" + ), + ) + .unwrap(); + let applied = trogon_atlas(&endpoint, &["apply", "-f", anchors.to_str().unwrap()]); + assert!(applied.status.success(), "{applied:?}"); + let artifact_id = "11111111-1111-4111-8111-111111111111"; + let schema_id = "22222222-2222-4222-8222-222222222222"; + let message_id = "33333333-3333-4333-8333-333333333333"; + let operation_id = "44444444-4444-4444-8444-444444444444"; + let content = "{\"type\":\"object\"}\r\n"; + let digest = "6f8f8b83ea6711930c12c3c32001521787644432b59f31251b32aefe9f0d494d"; + let spec = serde_json::json!({ + "title": "Order contract", + "artifacts": [{"memberId": {"value": artifact_id}, "name": {"value": "Order source"}, + "path": {"value": "contracts/order.json"}, "format": {"known": "KNOWN_JSON_SCHEMA"}, + "formatVersion": {"value": "2020-12"}, "mediaType": {"value": "application/schema+json"}, + "content": content, "digest": {"sha256": digest}}], + "schemas": [{"memberId": {"value": schema_id}, "name": {"value": "Order shape"}, + "definition": {"artifact": {"artifactId": {"value": artifact_id}, "expectedDigest": {"sha256": digest}}, + "selector": {"syntax": "json-pointer", "value": "/$defs/Order"}}}], + "messages": [{"memberId": {"value": message_id}, "name": {"value": "Order message"}, + "schemas": [{"role": "ROLE_PAYLOAD", "localSchema": {"value": schema_id}}], + "mapping": {"readModel": {"id": {"namespace": ns, "slug": "receipt", "version": "2"}}}}], + "operations": [{"memberId": {"value": operation_id}, "name": {"value": "Submit"}, + "binding": {"http": {"method": "POST", "path": "/orders", "responseStatusCodes": [201]}}, + "messages": [ + {"role": "ROLE_REQUEST", "messageId": {"value": message_id}}, + {"role": "ROLE_RESPONSE", "messageId": {"value": message_id}}]}], + "exposures": [{"provider": {"component": {"id": {"namespace": ns, "slug": "gateway", "version": "1"}}}, + "operationId": {"value": operation_id}}], + "usages": [{"caller": {"component": {"id": {"namespace": ns, "slug": "gateway", "version": "1"}}}, + "operationId": {"value": operation_id}}] + }); + + let manifest = serde_json::json!({"apiVersion": "eventmodel.atlas.trogonstack.com/v1alpha1", + "kind": "Specification", "metadata": {"namespace": ns, "name": "orders"}, "spec": spec}); + let source = directory.path().join("contract.yaml"); + std::fs::write(&source, manifest.to_string()).unwrap(); + let applied = trogon_atlas(&endpoint, &["apply", "-f", source.to_str().unwrap()]); + assert!(applied.status.success(), "{applied:?}"); + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + let request = pb::GetEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(pb::Id { + namespace: ns.into(), + slug: "orders".into(), + version: 1, + }), + }; + let stored = client + .get_entity(request.clone()) + .await + .unwrap() + .into_inner(); + let entity = stored.entity.as_ref().unwrap(); + let Some(pb::entity::Kind::Specification(contract)) = entity.kind.as_ref() else { + panic!("expected Specification") + }; + assert_eq!(contract.artifacts[0].content.as_bytes(), content.as_bytes()); + assert_eq!( + contract.artifacts[0].digest.as_ref().unwrap().sha256, + digest + ); + assert_eq!( + contract.schemas[0].member_id.as_ref().unwrap().value, + schema_id + ); + let occurrence_ids = [ + contract.schemas[0] + .definition + .as_ref() + .unwrap() + .connection_id + .as_ref() + .unwrap(), + contract.messages[0].schemas[0] + .connection_id + .as_ref() + .unwrap(), + contract.messages[0] + .mapping + .as_ref() + .unwrap() + .connection_id + .as_ref() + .unwrap(), + contract.operations[0].messages[0] + .connection_id + .as_ref() + .unwrap(), + contract.operations[0].messages[1] + .connection_id + .as_ref() + .unwrap(), + contract.exposures[0].connection_id.as_ref().unwrap(), + contract.usages[0].connection_id.as_ref().unwrap(), + ]; + assert_eq!( + occurrence_ids + .iter() + .map(|id| &id.value) + .collect::>() + .len(), + occurrence_ids.len() + ); + for spelling in [ + "specification", + "Specification", + "ENTITY_KIND_SPECIFICATION", + ] { + let got = trogon_atlas(&endpoint, &["get", &format!("{spelling}/{ns}/orders@1")]); + assert!(got.status.success(), "{got:?}"); + let loaded = trogon_atlas_client::manifest::parse_str(&got.stdout, "get.yaml").unwrap(); + assert_eq!(loaded[0].entity.kind, entity.kind); + } + let output = directory.path().join("exported"); + let exported = trogon_atlas( + &endpoint, + &["export", "--namespace", ns, "-o", output.to_str().unwrap()], + ); + assert!(exported.status.success(), "{exported:?}"); + let written = std::fs::read_to_string(output.join("specification.yaml")).unwrap(); + let loaded = trogon_atlas_client::manifest::parse_str(&written, "export.yaml").unwrap(); + assert_eq!(loaded[0].entity.kind, entity.kind); + let reapplied = trogon_atlas(&endpoint, &["apply", "-f", output.to_str().unwrap()]); + assert!(reapplied.status.success(), "{reapplied:?}"); + let after = client.get_entity(request).await.unwrap().into_inner(); + assert_eq!(stored.entity, after.entity); + assert_eq!(stored.etag, after.etag); +} diff --git a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json index f5e6d29f..453c88e5 100644 --- a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json +++ b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json @@ -259,6 +259,36 @@ ], "type": "string" }, + "trogonatlas.eventmodel.v1alpha1.ApiUsage": { + "additionalProperties": false, + "properties": { + "caller": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ProviderTarget" + }, + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "type": "string" + }, + "exposureId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "operationId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ArtifactPath": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.AutomationScenario": { "additionalProperties": false, "properties": { @@ -592,6 +622,41 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.ChannelOperationBinding": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "channel": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ChannelRef" + }, + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.ChannelRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, "trogonatlas.eventmodel.v1alpha1.Command": { "additionalProperties": false, "properties": { @@ -1276,6 +1341,248 @@ } ] }, + "trogonatlas.eventmodel.v1alpha1.ContentDigest": { + "additionalProperties": false, + "properties": { + "sha256": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractArtifact": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "digest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + }, + "doc": { + "type": "string" + }, + "format": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractFormat" + }, + "formatVersion": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.FormatVersion" + }, + "mediaType": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MediaType" + }, + "memberId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "name": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MemberName" + }, + "path": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ArtifactPath" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractDependency": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "specification", + "typeLibrary" + ] + } + } + ], + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "type": "string" + }, + "specification": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SpecificationRef" + }, + "typeLibrary": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.TypeLibraryRef" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.ContractFormat": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "known", + "other" + ] + } + } + ], + "properties": { + "known": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractFormat.Known" + }, + "other": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractFormat.Known": { + "enum": [ + "KNOWN_ASYNCAPI", + "KNOWN_AVRO", + "KNOWN_GRAPHQL", + "KNOWN_JSON_SCHEMA", + "KNOWN_OPENAPI", + "KNOWN_PROTOBUF", + "KNOWN_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.ContractMemberId": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractMemberKind": { + "enum": [ + "CONTRACT_MEMBER_KIND_ARTIFACT", + "CONTRACT_MEMBER_KIND_MESSAGE", + "CONTRACT_MEMBER_KIND_OPERATION", + "CONTRACT_MEMBER_KIND_SCHEMA", + "CONTRACT_MEMBER_KIND_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.ContractMemberRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "expectedDigest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + }, + "kind": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberKind" + }, + "memberId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "ownerUid": { + "type": "string" + }, + "specification": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SpecificationRef" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.ContractMessage": { + "additionalProperties": false, + "properties": { + "doc": { + "type": "string" + }, + "mapping": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MessageReadModelMapping" + }, + "memberId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "name": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MemberName" + }, + "schemas": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SchemaBinding" + }, + "type": "array" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractOperation": { + "additionalProperties": false, + "properties": { + "binding": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OperationBinding" + }, + "doc": { + "type": "string" + }, + "memberId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "messages": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OperationMessage" + }, + "type": "array" + }, + "name": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MemberName" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractSchema": { + "additionalProperties": false, + "properties": { + "definition": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SchemaDefinition" + }, + "doc": { + "type": "string" + }, + "memberId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "name": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MemberName" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ContractTypeRef": { + "additionalProperties": false, + "properties": { + "expectedArtifactId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "expectedDigest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + }, + "expectedSelector": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OpaqueTypeSelector" + }, + "schema": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberRef" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.DataStore": { "additionalProperties": false, "properties": { @@ -1451,6 +1758,7 @@ "ENTITY_KIND_SCREEN", "ENTITY_KIND_SERVICE_LEVEL_INDICATOR", "ENTITY_KIND_SERVICE_LEVEL_OBJECTIVE", + "ENTITY_KIND_SPECIFICATION", "ENTITY_KIND_STORYBOARD", "ENTITY_KIND_SUBDOMAIN", "ENTITY_KIND_SWIMLANE", @@ -1471,6 +1779,7 @@ "Screen", "ServiceLevelIndicator", "ServiceLevelObjective", + "Specification", "Storyboard", "Subdomain", "Swimlane", @@ -1514,6 +1823,7 @@ "service-level-objective", "service_level_indicator", "service_level_objective", + "specification", "storyboard", "subdomain", "swimlane", @@ -2276,6 +2586,55 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.FormatVersion": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.GraphqlOperationBinding": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.GraphqlOperationBinding.Kind" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.GraphqlOperationBinding.Kind": { + "enum": [ + "KIND_MUTATION", + "KIND_QUERY", + "KIND_SUBSCRIPTION", + "KIND_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.HttpOperationBinding": { + "additionalProperties": false, + "properties": { + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "responseStatusCodes": { + "items": { + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.LatencyThreshold": { "additionalProperties": false, "properties": { @@ -2286,6 +2645,209 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.LocalArtifactSelection": { + "additionalProperties": false, + "properties": { + "artifactId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "expectedDigest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.MediaType": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.MemberName": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.MessageReadModelMapping": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "readModel": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ReadModelRef" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.OpaqueTypeSelector": { + "additionalProperties": false, + "properties": { + "syntax": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.OperationBinding": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "http", + "rpc" + ] + } + }, + { + "not": { + "required": [ + "http", + "channel" + ] + } + }, + { + "not": { + "required": [ + "http", + "graphql" + ] + } + }, + { + "not": { + "required": [ + "http", + "other" + ] + } + }, + { + "not": { + "required": [ + "rpc", + "channel" + ] + } + }, + { + "not": { + "required": [ + "rpc", + "graphql" + ] + } + }, + { + "not": { + "required": [ + "rpc", + "other" + ] + } + }, + { + "not": { + "required": [ + "channel", + "graphql" + ] + } + }, + { + "not": { + "required": [ + "channel", + "other" + ] + } + }, + { + "not": { + "required": [ + "graphql", + "other" + ] + } + } + ], + "properties": { + "channel": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ChannelOperationBinding" + }, + "graphql": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.GraphqlOperationBinding" + }, + "http": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.HttpOperationBinding" + }, + "other": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OtherOperationBinding" + }, + "rpc": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.RpcOperationBinding" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.OperationMessage": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "type": "string" + }, + "messageId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "role": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OperationMessage.Role" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.OperationMessage.Role": { + "enum": [ + "ROLE_NOTIFICATION", + "ROLE_REQUEST", + "ROLE_RESPONSE", + "ROLE_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.OtherOperationBinding": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "protocol": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ProtocolName" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.ParameterName": { "additionalProperties": false, "properties": { @@ -2528,43 +3090,99 @@ }, "type": "object" }, - "trogonatlas.eventmodel.v1alpha1.ProjectRef": { + "trogonatlas.eventmodel.v1alpha1.ProjectRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.ProjectionRole": { + "enum": [ + "PROJECTION_ROLE_DELETE", + "PROJECTION_ROLE_UNSPECIFIED", + "PROJECTION_ROLE_UPSERT" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.ProtoSourceFile": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ProtocolName": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ProviderExposure": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "type": "string" + }, + "operationId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "provider": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ProviderTarget" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ProviderTarget": { "oneOf": [ { "$ref": "#/$defs/_ShorthandRef" }, { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "component", + "externalSystem" + ] + } + } + ], "properties": { - "id": { - "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + "component": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ComponentRef" + }, + "externalSystem": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ExternalSystemRef" } }, "type": "object" } ] }, - "trogonatlas.eventmodel.v1alpha1.ProjectionRole": { - "enum": [ - "PROJECTION_ROLE_DELETE", - "PROJECTION_ROLE_UNSPECIFIED", - "PROJECTION_ROLE_UPSERT" - ], - "type": "string" - }, - "trogonatlas.eventmodel.v1alpha1.ProtoSourceFile": { - "additionalProperties": false, - "properties": { - "content": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "type": "object" - }, - "trogonatlas.eventmodel.v1alpha1.ProtocolName": { + "trogonatlas.eventmodel.v1alpha1.QualifiedProtoName": { "additionalProperties": false, "properties": { "value": { @@ -2899,6 +3517,18 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.RpcOperationBinding": { + "additionalProperties": false, + "properties": { + "method": { + "type": "string" + }, + "service": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.Schema": { "additionalProperties": false, "properties": { @@ -2934,6 +3564,149 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.SchemaBinding": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "localSchema", + "contractType" + ] + } + }, + { + "not": { + "required": [ + "localSchema", + "schema" + ] + } + }, + { + "not": { + "required": [ + "localSchema", + "libraryType" + ] + } + }, + { + "not": { + "required": [ + "contractType", + "schema" + ] + } + }, + { + "not": { + "required": [ + "contractType", + "libraryType" + ] + } + }, + { + "not": { + "required": [ + "schema", + "libraryType" + ] + } + } + ], + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "contractType": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractTypeRef" + }, + "doc": { + "type": "string" + }, + "libraryType": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.TypeLibraryTypeRef" + }, + "localSchema": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberId" + }, + "role": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SchemaBinding.Role" + }, + "schema": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SchemaRef" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.SchemaBinding.Role": { + "enum": [ + "ROLE_HEADERS", + "ROLE_PAYLOAD", + "ROLE_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.SchemaDefinition": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "artifact", + "schema" + ] + } + } + ], + "properties": { + "artifact": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.LocalArtifactSelection" + }, + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "schema": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SchemaRef" + }, + "selector": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.OpaqueTypeSelector" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.SchemaRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, "trogonatlas.eventmodel.v1alpha1.Screen": { "additionalProperties": false, "properties": { @@ -3290,6 +4063,93 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.Specification": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractArtifact" + }, + "type": "array" + }, + "dependencies": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractDependency" + }, + "type": "array" + }, + "doc": { + "type": "string" + }, + "exposures": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ProviderExposure" + }, + "type": "array" + }, + "messages": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMessage" + }, + "type": "array" + }, + "metadata": { + "items": { + "properties": { + "@type": { + "type": "string" + } + }, + "required": [ + "@type" + ], + "type": "object" + }, + "type": "array" + }, + "operations": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractOperation" + }, + "type": "array" + }, + "schemas": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractSchema" + }, + "type": "array" + }, + "supersedes": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + }, + "title": { + "type": "string" + }, + "usages": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ApiUsage" + }, + "type": "array" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.SpecificationRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, "trogonatlas.eventmodel.v1alpha1.StoreClass": { "additionalProperties": false, "allOf": [ @@ -4076,6 +4936,25 @@ } ] }, + "trogonatlas.eventmodel.v1alpha1.TypeLibraryTypeRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "library": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.TypeLibraryRef" + }, + "message": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.QualifiedProtoName" + } + }, + "type": "object" + } + ] + }, "trogonatlas.eventmodel.v1alpha1.TypeRef": { "oneOf": [ { @@ -5224,6 +6103,29 @@ } } } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "ENTITY_KIND_SPECIFICATION", + "Specification", + "specification" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "spec": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.Specification" + } + } + } } ], "description": "CRD-style YAML document accepted by `trogon-atlas apply` / trogon-atlas-client manifest loading.", diff --git a/rsworkspace/crates/trogon-atlas-client/src/export.rs b/rsworkspace/crates/trogon-atlas-client/src/export.rs index 08deb777..50931302 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/export.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/export.rs @@ -141,6 +141,7 @@ pub(crate) fn entity_label(entity: &pb::Entity) -> String { K::TypeLibrary(x) => x.id.as_ref(), K::Channel(x) => x.id.as_ref(), K::DataStore(x) => x.id.as_ref(), + K::Specification(x) => x.id.as_ref(), }; match id { Some(id) => pb::canonical::id_string(entity_kind_of(kind), id), @@ -185,6 +186,7 @@ fn entity_kind_of(kind: &pb::entity::Kind) -> pb::EntityKind { K::TypeLibrary(_) => pb::EntityKind::TypeLibrary, K::Channel(_) => pb::EntityKind::Channel, K::DataStore(_) => pb::EntityKind::DataStore, + K::Specification(_) => pb::EntityKind::Specification, } } diff --git a/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs b/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs index 0f709874..a176bc5b 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs @@ -10,7 +10,7 @@ use crate::{ reconcile_branch_connection_ids, reconcile_connection_ids, renew_connection_ids, validate_stored_connection_ids, ConnectionError, ConnectionId, }, - stamp_system, + specification, stamp_system, }; /// Prepare an untrusted write against the value guarded by the store's CAS. @@ -19,9 +19,15 @@ pub fn prepare_entity( proposed: &Entity, ) -> Result { let mut prepared = proposed.clone(); + if let Some(previous) = previous { + if specification::equivalent_manifest(previous, proposed)? { + return Ok(previous.clone()); + } + } + specification::prepare_members(previous, &mut prepared, false)?; match previous { Some(previous) => { - if equivalent_with_unassigned_ids(previous, proposed)? { + if equivalent_with_unassigned_ids(previous, &prepared)? { prepared = previous.clone(); } else { reconcile_connection_ids(previous, &mut prepared)?; @@ -40,6 +46,8 @@ pub fn prepare_landing( previous: Option<&Entity>, branch: &Entity, ) -> Result { + let removed_members = specification::removed_members(base, previous, branch)?; + specification::reject_removed(branch, &removed_members)?; let removed = removed_base_ids(base, previous, branch)?; if let Some(id) = connection_occurrences(branch)? .into_iter() @@ -50,6 +58,7 @@ pub fn prepare_landing( } let previous = previous.filter(|entity| same_incarnation(entity, branch)); let mut prepared = branch.clone(); + specification::prepare_members(previous, &mut prepared, true)?; if let Some(previous) = previous { reconcile_branch_connection_ids(previous, &mut prepared)?; } @@ -75,6 +84,13 @@ pub fn prepare_resolution( let removed = removed_base_ids(base, discarded, selected)?; let mut resolved = selected.clone(); renew_connection_ids(&mut resolved, &removed)?; + let removed_members = specification::removed_members(base, discarded, selected)?; + specification::restore_members(&mut resolved, &removed_members)?; + crate::connections::renew_retargeted_connection_ids(selected, &mut resolved)?; + crate::connections::assign_connection_ids(&mut resolved)?; + specification::rewrite_exposure_selections(selected, &mut resolved); + crate::connections::renew_retargeted_connection_ids(selected, &mut resolved)?; + crate::connections::assign_connection_ids(&mut resolved)?; prepare_landing(base, discarded, &resolved) } diff --git a/rsworkspace/crates/trogon-atlas-core/src/connections.rs b/rsworkspace/crates/trogon-atlas-core/src/connections.rs index e410794f..75fa7fc9 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/connections.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/connections.rs @@ -95,6 +95,8 @@ pub enum ConnectionError { UnknownId(ConnectionId), #[error("connection ID {0} from the branch base was removed on the opposing side")] RemovedFromBranchBase(ConnectionId), + #[error("specification identity error: {0}")] + Member(String), #[error("connection encoding error: {0}")] Encoding(String), } @@ -200,6 +202,28 @@ fn visit( callback: &mut impl FnMut(&mut DynamicMessage, ConnectionOccurrence) -> Result<(), ConnectionError>, ) -> Result<(), ConnectionError> { let descriptor = message.descriptor(); + if descriptor.get_field_by_name("connection_id").is_some() + && wrapper_target(descriptor.full_name()).is_none() + { + let mut endpoint = message.clone(); + for name in ["connection_id", "doc"] { + if let Some(field) = descriptor.get_field_by_name(name) { + endpoint.clear_field(&field); + } + } + let mut signature = format!("{role}:{}:", descriptor.full_name()).into_bytes(); + signature.extend_from_slice(&(predecessor.len() as u64).to_le_bytes()); + signature.extend_from_slice(predecessor); + signature.extend(endpoint.encode_to_vec()); + let occurrence = ConnectionOccurrence { + id: read_id(message)?, + path: path.to_owned(), + target: None, + signature, + }; + callback(message, occurrence)?; + return Ok(()); + } if let Some((field, kind)) = wrapper_target(descriptor.full_name()) { let endpoint = message.get_field_by_name(field); let target_message = endpoint.as_deref().and_then(Value::as_message); @@ -248,6 +272,14 @@ fn visit( } else { predecessor.to_vec() }; + let mut after = after; + if let Some(value) = message.get_field_by_name("member_id") { + if let Some(id) = value.as_message() { + let encoded = id.encode_to_vec(); + after.extend_from_slice(&(encoded.len() as u64).to_le_bytes()); + after.extend(encoded); + } + } let fields: Vec<_> = descriptor .fields() .filter(|field| message.has_field(field)) diff --git a/rsworkspace/crates/trogon-atlas-core/src/lib.rs b/rsworkspace/crates/trogon-atlas-core/src/lib.rs index c33fcf4c..67578045 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/lib.rs @@ -22,6 +22,8 @@ pub mod operation_id; pub mod refs; pub mod schema; pub mod semantic_eq; +pub mod specification; +mod specification_refs; pub mod system_meta; pub mod transcode; pub mod writer_lease; diff --git a/rsworkspace/crates/trogon-atlas-core/src/refs.rs b/rsworkspace/crates/trogon-atlas-core/src/refs.rs index fa2cc784..fdd4c8d0 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/refs.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/refs.rs @@ -85,6 +85,7 @@ pub fn outbound_refs(entity: &Entity) -> Vec { EntityOneof::TypeLibrary(x) => x.supersedes.as_ref(), EntityOneof::Channel(x) => x.supersedes.as_ref(), EntityOneof::DataStore(x) => x.supersedes.as_ref(), + EntityOneof::Specification(x) => x.supersedes.as_ref(), }; if let Some(s) = supersedes_of { if let Some(self_kind) = entity_kind(entity) { @@ -793,6 +794,7 @@ pub fn outbound_refs(entity: &Entity) -> Vec { } } } + EntityOneof::Specification(x) => crate::specification_refs::outbound(x, &mut out), EntityOneof::DataStore(x) => { for (i, attachment) in x.attachments.iter().enumerate() { if let Some(id) = attachment.read_model.as_ref().and_then(|r| r.id.as_ref()) { @@ -1077,6 +1079,7 @@ pub fn entity_kind(entity: &Entity) -> Option { EntityOneof::TypeLibrary(_) => EntityKind::TypeLibrary, EntityOneof::Channel(_) => EntityKind::Channel, EntityOneof::DataStore(_) => EntityKind::DataStore, + EntityOneof::Specification(_) => EntityKind::Specification, }) } @@ -1117,6 +1120,7 @@ pub fn entity_id(entity: &Entity) -> Option<&Id> { EntityOneof::TypeLibrary(x) => x.id.as_ref(), EntityOneof::Channel(x) => x.id.as_ref(), EntityOneof::DataStore(x) => x.id.as_ref(), + EntityOneof::Specification(x) => x.id.as_ref(), } } @@ -1177,6 +1181,7 @@ pub fn retarget_refs( EntityOneof::TypeLibrary(x) => x.supersedes.as_mut(), EntityOneof::Channel(x) => x.supersedes.as_mut(), EntityOneof::DataStore(x) => x.supersedes.as_mut(), + EntityOneof::Specification(x) => x.supersedes.as_mut(), }; if self_kind_matches { if let Some(s) = supersedes_opt { @@ -1940,6 +1945,9 @@ pub fn retarget_refs( } // An observation pins an incarnation and occurrence, so changing only its owner key cannot select a replacement. } + EntityOneof::Specification(x) => { + crate::specification_refs::retarget(x, from_kind, from_id, to_id, &mut rewritten); + } EntityOneof::DataStore(x) => { if from_kind == EntityKind::ReadModel { for (i, attachment) in x.attachments.iter_mut().enumerate() { @@ -2137,6 +2145,7 @@ fn matches_kind(kind: &EntityOneof, ek: EntityKind) -> bool { | (EntityOneof::TypeLibrary(_), EntityKind::TypeLibrary) | (EntityOneof::Channel(_), EntityKind::Channel) | (EntityOneof::DataStore(_), EntityKind::DataStore) + | (EntityOneof::Specification(_), EntityKind::Specification) ) } diff --git a/rsworkspace/crates/trogon-atlas-core/src/specification.rs b/rsworkspace/crates/trogon-atlas-core/src/specification.rs new file mode 100644 index 00000000..2ec71294 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/specification.rs @@ -0,0 +1,874 @@ +//! Native contract values, local graph validation and exact member selection. + +use std::{ + collections::{HashMap, HashSet}, + fmt, + str::FromStr, +}; + +use prost::Message; +use sha2::{Digest, Sha256}; +use trogon_atlas_proto::{ + self as pb, contract_dependency, entity, operation_binding, schema_binding, schema_definition, + ContractMemberKind as Kind, Entity, Specification, +}; + +use crate::connections::{self, ConnectionError}; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct SpecificationError(pub String); + +type Result = std::result::Result; +type MemberIdentity = Option; + +fn require(ok: bool, detail: impl Into) -> Result<()> { + if ok { + Ok(()) + } else { + Err(SpecificationError(detail.into())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MemberId(uuid::Uuid); +impl MemberId { + #[must_use] + pub fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } +} +impl Default for MemberId { + fn default() -> Self { + Self::new() + } +} +impl fmt::Display for MemberId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl FromStr for MemberId { + type Err = SpecificationError; + fn from_str(value: &str) -> Result { + let parsed = uuid::Uuid::parse_str(value) + .map_err(|_| SpecificationError("invalid member UUID".into()))?; + require( + !parsed.is_nil() && parsed.to_string() == value, + "member ID must be a non-nil lowercase hyphenated UUID", + )?; + Ok(Self(parsed)) + } +} +impl From for pb::ContractMemberId { + fn from(value: MemberId) -> Self { + Self { + value: value.to_string(), + } + } +} + +#[must_use] +pub fn content_digest(content: &str) -> pb::ContentDigest { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut sha256 = String::with_capacity(64); + for byte in Sha256::digest(content.as_bytes()) { + sha256.push(char::from(HEX[usize::from(byte >> 4)])); + sha256.push(char::from(HEX[usize::from(byte & 15)])); + } + pb::ContentDigest { sha256 } +} + +#[must_use] +pub fn is_api(value: &Specification) -> bool { + value.exposures.iter().any(|exposure| { + exposure.operation_id.is_some() + && value + .operations + .iter() + .any(|operation| operation.member_id == exposure.operation_id) + }) +} + +fn member_id(id: Option<&pb::ContractMemberId>) -> Result { + id.ok_or_else(|| SpecificationError("missing member ID".into()))? + .value + .parse() +} + +macro_rules! members { + ($value:expr, $callback:expr) => {{ + let value = &mut *$value; + #[allow(unused_mut)] + let mut callback = $callback; + for member in &mut value.artifacts { + callback(Kind::Artifact, &mut member.member_id)?; + } + for member in &mut value.schemas { + callback(Kind::Schema, &mut member.member_id)?; + } + for member in &mut value.messages { + callback(Kind::Message, &mut member.member_id)?; + } + for member in &mut value.operations { + callback(Kind::Operation, &mut member.member_id)?; + } + }}; +} + +fn active(value: &Specification, persisted: bool) -> Result> { + let mut output = HashMap::new(); + members!(&mut value.clone(), |kind, + id: &mut MemberIdentity| + -> Result<()> { + if id.is_some() || persisted { + let id = member_id(id.as_ref())?; + require( + output.insert(id, kind).is_none(), + format!("duplicate member ID {id}"), + )?; + } + Ok(()) + }); + Ok(output) +} + +fn spec(entity: &Entity) -> Option<&Specification> { + match entity.kind.as_ref() { + Some(entity::Kind::Specification(s)) => Some(s), + _ => None, + } +} +fn spec_mut(entity: &mut Entity) -> Option<&mut Specification> { + match entity.kind.as_mut() { + Some(entity::Kind::Specification(s)) => Some(s), + _ => None, + } +} +fn connection_error(error: SpecificationError) -> ConnectionError { + ConnectionError::Member(error.0) +} + +pub(crate) fn validate_stored_members(entity: &Entity) -> std::result::Result<(), ConnectionError> { + if let Some(value) = spec(entity) { + active(value, true).map_err(connection_error)?; + } + Ok(()) +} + +pub(crate) fn prepare_members( + previous: Option<&Entity>, + proposed: &mut Entity, + trusted: bool, +) -> std::result::Result<(), ConnectionError> { + let Some(value) = spec_mut(proposed) else { + return Ok(()); + }; + let prior = previous.and_then(spec); + let old = prior + .map(|s| active(s, true)) + .transpose() + .map_err(connection_error)? + .unwrap_or_default(); + let supplied = active(value, false).map_err(connection_error)?; + if !old.is_empty() && supplied.is_empty() { + return Err(ConnectionError::Member("edited manifest cannot strip every retained member identity; preserve returned member IDs".into())); + } + if let Some(prior) = prior { + macro_rules! retained { + ($collection:ident, $same:expr) => { + for proposed in &value.$collection { + if proposed.member_id.is_none() { + if prior.$collection.iter().any(|old| { + !value.$collection.iter().any(|m| m.member_id == old.member_id) + && old.name == proposed.name && ($same)(old, proposed) + }) { + return Err(ConnectionError::Member("retained member identity was stripped; preserve returned member IDs".into())); + } + } + } + }; + } + retained!( + artifacts, + |a: &pb::ContractArtifact, b: &pb::ContractArtifact| a.content == b.content + && a.format == b.format + && a.format_version == b.format_version + && a.media_type == b.media_type + ); + retained!(schemas, |_: &pb::ContractSchema, _: &pb::ContractSchema| { + true + }); + retained!( + messages, + |_: &pb::ContractMessage, _: &pb::ContractMessage| true + ); + retained!( + operations, + |_: &pb::ContractOperation, _: &pb::ContractOperation| true + ); + } + members!(value, |kind, + id: &mut MemberIdentity| + -> std::result::Result<(), ConnectionError> { + if let Some(id) = id { + let parsed = id.value.parse::().map_err(connection_error)?; + if previous.is_some() && !trusted && old.get(&parsed) != Some(&kind) { + return Err(ConnectionError::Member(format!( + "member {parsed} is not active in this member kind; omit IDs for additions" + ))); + } + if old.get(&parsed).is_some_and(|old_kind| *old_kind != kind) { + return Err(ConnectionError::Member("member kind cannot change".into())); + } + } else { + *id = Some(MemberId::new().into()); + } + Ok(()) + }); + if let Some(prior) = prior { + for artifact in &value.artifacts { + if let Some(old) = prior + .artifacts + .iter() + .find(|a| a.member_id == artifact.member_id) + { + require(old.content == artifact.content && old.format == artifact.format && old.format_version == artifact.format_version && old.media_type == artifact.media_type && old.digest == artifact.digest, + "artifact bytes, format, media type and digest are immutable within member identity").map_err(connection_error)?; + } + } + } + Ok(()) +} + +pub(crate) fn equivalent_manifest( + previous: &Entity, + proposed: &Entity, +) -> std::result::Result { + validate_stored_members(previous)?; + let Some(desired) = spec(proposed) else { + return Ok(false); + }; + connections::validate_stored_connection_ids(previous)?; + active(desired, false).map_err(connection_error)?; + let mut comparison = previous.clone(); + if active(desired, false).map_err(connection_error)?.is_empty() { + if let Some(value) = spec_mut(&mut comparison) { + members!( + value, + |_, id: &mut MemberIdentity| -> std::result::Result<(), ConnectionError> { + *id = None; + Ok(()) + } + ); + } + } + if crate::semantically_equal(&comparison, proposed) { + return Ok(true); + } + connections::equivalent_with_unassigned_ids(&comparison, proposed) +} + +pub(crate) fn removed_members( + base: Option<&Entity>, + opposing: Option<&Entity>, + selected: &Entity, +) -> std::result::Result, ConnectionError> { + for entity in base + .into_iter() + .chain(opposing) + .chain(std::iter::once(selected)) + { + validate_stored_members(entity)?; + } + let same = |e: &&Entity| { + e.system + .as_ref() + .zip(selected.system.as_ref()) + .is_some_and(|(a, b)| !a.uid.is_empty() && a.uid == b.uid) + }; + let Some(base) = base.filter(same).and_then(spec) else { + return Ok(HashSet::new()); + }; + let Some(opposing) = opposing.filter(same).and_then(spec) else { + return Ok(HashSet::new()); + }; + let current = active(opposing, true).map_err(connection_error)?; + Ok(active(base, true) + .map_err(connection_error)? + .into_keys() + .filter(|id| !current.contains_key(id)) + .collect()) +} + +pub(crate) fn reject_removed( + entity: &Entity, + removed: &HashSet, +) -> std::result::Result<(), ConnectionError> { + if let Some(value) = spec(entity) { + if let Some(id) = active(value, true) + .map_err(connection_error)? + .keys() + .find(|id| removed.contains(id)) + { + return Err(ConnectionError::Member(format!( + "member {id} from branch base was removed on opposing side" + ))); + } + } + Ok(()) +} + +pub(crate) fn restore_members( + entity: &mut Entity, + removed: &HashSet, +) -> std::result::Result<(), ConnectionError> { + let Some(value) = spec_mut(entity) else { + return Ok(()); + }; + let mut replacements = HashMap::new(); + members!(value, |_, + id: &mut MemberIdentity| + -> std::result::Result<(), ConnectionError> { + let parsed = member_id(id.as_ref()).map_err(connection_error)?; + if removed.contains(&parsed) { + let fresh: pb::ContractMemberId = MemberId::new().into(); + replacements.insert(parsed.to_string(), fresh.clone()); + *id = Some(fresh); + } + Ok(()) + }); + let rewrite = |id: &mut MemberIdentity| { + if let Some(fresh) = id.as_ref().and_then(|id| replacements.get(&id.value)) { + *id = Some(fresh.clone()); + } + }; + for schema in &mut value.schemas { + if let Some(schema_definition::Target::Artifact(artifact)) = + schema.definition.as_mut().and_then(|d| d.target.as_mut()) + { + rewrite(&mut artifact.artifact_id); + } + } + for message in &mut value.messages { + for binding in &mut message.schemas { + if let Some(schema_binding::Type::LocalSchema(id)) = binding.r#type.as_mut() { + if let Some(fresh) = replacements.get(&id.value) { + *id = fresh.clone(); + } + } + } + } + for operation in &mut value.operations { + for message in &mut operation.messages { + rewrite(&mut message.message_id); + } + } + for exposure in &mut value.exposures { + rewrite(&mut exposure.operation_id); + } + for usage in &mut value.usages { + rewrite(&mut usage.operation_id); + } + Ok(()) +} + +#[derive(Debug)] +pub enum ContractMember<'a> { + Artifact(&'a pb::ContractArtifact), + Schema(&'a pb::ContractSchema), + Message(&'a pb::ContractMessage), + Operation(&'a pb::ContractOperation), +} + +pub fn resolve_member<'a>( + entity: &'a Entity, + reference: &pb::ContractMemberRef, +) -> Result> { + let value = + spec(entity).ok_or_else(|| SpecificationError("owner is not a Specification".into()))?; + require( + reference + .specification + .as_ref() + .and_then(|r| r.id.as_ref()) + .is_some_and(|id| Some(id) == value.id.as_ref()) + && !reference.owner_uid.is_empty() + && entity + .system + .as_ref() + .is_some_and(|s| s.uid == reference.owner_uid), + "member reference does not identify exact owner incarnation", + )?; + active(value, true)?; + let id = member_id(reference.member_id.as_ref())?; + let matches = |member: &Option| { + member.as_ref().is_some_and(|m| m.value == id.to_string()) + }; + require( + reference.kind == Kind::Artifact as i32 || reference.expected_digest.is_none(), + "only artifact member references may pin a digest", + )?; + match Kind::try_from(reference.kind) { + Ok(Kind::Artifact) => { + let member = value + .artifacts + .iter() + .find(|m| matches(&m.member_id)) + .ok_or_else(|| SpecificationError("artifact member missing".into()))?; + if let Some(digest) = &reference.expected_digest { + require( + member.digest.as_ref() == Some(digest) + && content_digest(&member.content) == *digest, + "artifact digest pin mismatch", + )?; + } + Ok(ContractMember::Artifact(member)) + } + Ok(Kind::Schema) => value + .schemas + .iter() + .find(|m| matches(&m.member_id)) + .map(ContractMember::Schema) + .ok_or_else(|| SpecificationError("schema member missing".into())), + Ok(Kind::Message) => value + .messages + .iter() + .find(|m| matches(&m.member_id)) + .map(ContractMember::Message) + .ok_or_else(|| SpecificationError("message member missing".into())), + Ok(Kind::Operation) => value + .operations + .iter() + .find(|m| matches(&m.member_id)) + .map(ContractMember::Operation) + .ok_or_else(|| SpecificationError("operation member missing".into())), + _ => Err(SpecificationError("invalid member kind".into())), + } +} + +pub fn resolve_type<'a>( + entity: &'a Entity, + reference: &pb::ContractTypeRef, +) -> Result<&'a pb::ContractSchema> { + let reference_schema = reference + .schema + .as_ref() + .ok_or_else(|| SpecificationError("missing schema member reference".into()))?; + let ContractMember::Schema(schema) = resolve_member(entity, reference_schema)? else { + return Err(SpecificationError( + "type reference must select schema member".into(), + )); + }; + let definition = schema + .definition + .as_ref() + .ok_or_else(|| SpecificationError("missing schema definition".into()))?; + let Some(schema_definition::Target::Artifact(selection)) = definition.target.as_ref() else { + return Err(SpecificationError( + "external contract type must select artifact-backed schema".into(), + )); + }; + member_id(reference.expected_artifact_id.as_ref())?; + require( + reference.expected_digest.is_some() + && selection.artifact_id == reference.expected_artifact_id + && selection.expected_digest == reference.expected_digest + && definition.selector == reference.expected_selector, + "schema artifact identity, digest or selector pin mismatch", + )?; + let value = spec(entity).ok_or_else(|| SpecificationError("missing Specification".into()))?; + let artifact = value + .artifacts + .iter() + .find(|a| a.member_id == selection.artifact_id) + .ok_or_else(|| SpecificationError("selected artifact missing".into()))?; + require( + artifact.digest == reference.expected_digest + && Some(content_digest(&artifact.content)) == artifact.digest, + "selected artifact bytes mismatch", + )?; + Ok(schema) +} + +fn exact(id: Option<&pb::Id>) -> Result<()> { + let id = id.ok_or_else(|| SpecificationError("missing exact entity reference".into()))?; + require( + id.version > 0 + && crate::is_safe_id_component(&id.namespace) + && crate::is_safe_id_component(&id.slug), + "invalid exact entity reference", + ) +} +fn provider(target: Option<&pb::ProviderTarget>) -> Result<()> { + match target.and_then(|p| p.target.as_ref()) { + Some(pb::provider_target::Target::Component(r)) => exact(r.id.as_ref()), + Some(pb::provider_target::Target::ExternalSystem(r)) => exact(r.id.as_ref()), + None => Err(SpecificationError("missing provider target".into())), + } +} +fn selector(value: Option<&pb::OpaqueTypeSelector>) -> Result<()> { + if let Some(value) = value { + require( + !value.syntax.trim().is_empty() && !value.value.trim().is_empty(), + "opaque selector requires syntax and value", + )?; + } + Ok(()) +} + +/// Validate native structure and all same-owner selections. External targets require authorized backend resolution. +pub fn validate_specification(value: &Specification) -> Result<()> { + exact(value.id.as_ref())?; + require( + !value.title.trim().is_empty(), + "Specification title is required", + )?; + require( + !value.artifacts.is_empty() || !value.schemas.is_empty() || !value.messages.is_empty(), + "Specification requires artifact, schema or message", + )?; + require( + value.artifacts.len() + value.schemas.len() + value.messages.len() + value.operations.len() + <= 1024, + "Specification member limit exceeded", + )?; + require( + value.encoded_len() <= 1024 * 1024, + "Specification encoded size limit exceeded", + )?; + let ids = active(value, true)?; + let local = |id: Option<&pb::ContractMemberId>, kind| -> Result<()> { + let id = member_id(id)?; + require( + ids.get(&id) == Some(&kind), + format!("missing or wrong-kind local member {id}"), + ) + }; + for names in [ + value + .artifacts + .iter() + .map(|m| m.name.as_ref()) + .collect::>(), + value.schemas.iter().map(|m| m.name.as_ref()).collect(), + value.messages.iter().map(|m| m.name.as_ref()).collect(), + value.operations.iter().map(|m| m.name.as_ref()).collect(), + ] { + let mut seen = HashSet::new(); + for name in names { + let name = name.ok_or_else(|| SpecificationError("member name is required".into()))?; + require( + !name.value.trim().is_empty() && seen.insert(&name.value), + "member names must be nonblank and unique within kind", + )?; + } + } + let mut paths = HashSet::new(); + let mut bytes = 0; + for artifact in &value.artifacts { + let path = artifact + .path + .as_ref() + .ok_or_else(|| SpecificationError("artifact path required".into()))?; + require( + !path.value.contains('\\') + && path + .value + .split('/') + .all(|s| !s.is_empty() && s != "." && s != "..") + && paths.insert(&path.value), + "artifact path must be unique relative POSIX path", + )?; + require( + !artifact.content.is_empty() && artifact.content.len() <= 256 * 1024, + "artifact source text empty or exceeds limit", + )?; + bytes += artifact.content.len(); + require( + artifact.digest.as_ref() == Some(&content_digest(&artifact.content)), + "artifact digest mismatch", + )?; + require( + artifact + .media_type + .as_ref() + .is_some_and(|m| !m.value.trim().is_empty() && m.value.is_ascii()), + "media type must be nonblank ASCII", + )?; + if let Some(version) = &artifact.format_version { + require( + !version.value.trim().is_empty(), + "format version must be nonblank", + )?; + } + match artifact.format.as_ref().and_then(|f| f.kind.as_ref()) { + Some(pb::contract_format::Kind::Known(k)) => require( + pb::contract_format::Known::try_from(*k).is_ok() && *k != 0, + "invalid known contract format", + )?, + Some(pb::contract_format::Kind::Other(name)) => { + require(!name.trim().is_empty(), "other format name required")?; + } + None => return Err(SpecificationError("contract format required".into())), + } + } + require(bytes <= 512 * 1024, "total artifact byte limit exceeded")?; + for schema in &value.schemas { + let definition = schema + .definition + .as_ref() + .ok_or_else(|| SpecificationError("schema definition required".into()))?; + selector(definition.selector.as_ref())?; + match definition.target.as_ref() { + Some(schema_definition::Target::Artifact(selection)) => { + local(selection.artifact_id.as_ref(), Kind::Artifact)?; + let artifact = value + .artifacts + .iter() + .find(|a| a.member_id == selection.artifact_id) + .ok_or_else(|| SpecificationError("artifact missing".into()))?; + require( + selection.expected_digest.is_some() + && selection.expected_digest == artifact.digest, + "schema definition artifact digest mismatch", + )?; + } + Some(schema_definition::Target::Schema(r)) => { + exact(r.id.as_ref())?; + require( + definition.selector.is_none(), + "native Schema definition cannot have opaque selector", + )?; + } + None => { + return Err(SpecificationError( + "schema definition target required".into(), + )); + } + } + } + for message in &value.messages { + if let Some(mapping) = &message.mapping { + exact(mapping.read_model.as_ref().and_then(|r| r.id.as_ref()))?; + } + for binding in &message.schemas { + require( + pb::schema_binding::Role::try_from(binding.role).is_ok() && binding.role != 0, + "schema binding role required", + )?; + match binding.r#type.as_ref() { + Some(schema_binding::Type::LocalSchema(id)) => local(Some(id), Kind::Schema)?, + Some(schema_binding::Type::Schema(r)) => exact(r.id.as_ref())?, + Some(schema_binding::Type::LibraryType(r)) => { + exact(r.library.as_ref().and_then(|r| r.id.as_ref()))?; + let name = r + .message + .as_ref() + .map(|n| n.value.as_str()) + .unwrap_or_default(); + require( + !name.is_empty() + && name.split('.').all(|s| { + !s.is_empty() + && (s.as_bytes()[0].is_ascii_alphabetic() || s.starts_with('_')) + && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + }), + "qualified protobuf name required", + )?; + } + Some(schema_binding::Type::ContractType(r)) => { + let owner = r.schema.as_ref().ok_or_else(|| { + SpecificationError("contract type schema required".into()) + })?; + exact(owner.specification.as_ref().and_then(|s| s.id.as_ref()))?; + require( + owner.specification.as_ref().and_then(|s| s.id.as_ref()) != value.id.as_ref(), + "same-owner schema selections must use LocalSchema instead of external contract type pins", + )?; + require( + owner.kind == Kind::Schema as i32 + && !owner.owner_uid.trim().is_empty() + && owner.expected_digest.is_none(), + "contract type requires schema incarnation selection", + )?; + member_id(owner.member_id.as_ref())?; + member_id(r.expected_artifact_id.as_ref())?; + require( + r.expected_digest.as_ref().is_some_and(|d| { + d.sha256.len() == 64 + && d.sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }), + "contract type digest required", + )?; + selector(r.expected_selector.as_ref())?; + } + None => return Err(SpecificationError("schema binding type required".into())), + } + } + } + for operation in &value.operations { + require( + !operation.messages.is_empty(), + "operation requires message occurrence", + )?; + for occurrence in &operation.messages { + local(occurrence.message_id.as_ref(), Kind::Message)?; + require( + pb::operation_message::Role::try_from(occurrence.role).is_ok() + && occurrence.role != 0, + "operation message role required", + )?; + } + if let Some(binding) = &operation.binding { + match binding.kind.as_ref() { + Some(operation_binding::Kind::Http(b)) => { + require( + !b.method.is_empty() + && b.method.bytes().all(|c| { + c.is_ascii_uppercase() + || c.is_ascii_digit() + || b"!#$%&'*+-.^_`|~".contains(&c) + }) + && b.path.starts_with('/'), + "invalid HTTP method or path", + )?; + let mut seen = HashSet::new(); + require( + b.response_status_codes + .iter() + .all(|s| (100..=599).contains(s) && seen.insert(s)), + "HTTP statuses must be distinct and in 100..599", + )?; + } + Some(operation_binding::Kind::Rpc(b)) => require( + !b.service.trim().is_empty() && !b.method.trim().is_empty(), + "RPC service and method required", + )?, + Some(operation_binding::Kind::Graphql(b)) => require( + pb::graphql_operation_binding::Kind::try_from(b.kind).is_ok() + && b.kind != 0 + && !b.field.trim().is_empty(), + "GraphQL kind and field required", + )?, + Some(operation_binding::Kind::Channel(b)) => { + exact(b.channel.as_ref().and_then(|r| r.id.as_ref()))?; + } + Some(operation_binding::Kind::Other(b)) => { + let name = b + .protocol + .as_ref() + .map(|p| p.value.as_str()) + .unwrap_or_default(); + require( + !name.is_empty() + && name.as_bytes()[0].is_ascii_lowercase() + && name.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b".+-".contains(&b) + }) + && !b.description.trim().is_empty(), + "other binding protocol and description required", + )?; + } + None => { + return Err(SpecificationError( + "operation binding alternative required".into(), + )); + } + } + } + } + let mut providers = HashSet::new(); + for exposure in &value.exposures { + provider(exposure.provider.as_ref())?; + local(exposure.operation_id.as_ref(), Kind::Operation)?; + require( + providers.insert(( + exposure.provider.as_ref().map(Message::encode_to_vec), + exposure.operation_id.as_ref().map(|id| &id.value), + )), + "duplicate provider-operation exposure", + )?; + } + for usage in &value.usages { + provider(usage.caller.as_ref())?; + local(usage.operation_id.as_ref(), Kind::Operation)?; + if let Some(id) = &usage.exposure_id { + let exposure = value + .exposures + .iter() + .find(|e| e.connection_id.as_ref() == Some(id)) + .ok_or_else(|| SpecificationError("usage exposure missing".into()))?; + require( + exposure.operation_id == usage.operation_id, + "usage exposure selects different operation", + )?; + } + } + let mut dependencies = HashSet::new(); + for dependency in &value.dependencies { + let (kind, id) = match dependency.target.as_ref() { + Some(contract_dependency::Target::Specification(r)) => { + (pb::EntityKind::Specification, r.id.as_ref()) + } + Some(contract_dependency::Target::TypeLibrary(r)) => { + (pb::EntityKind::TypeLibrary, r.id.as_ref()) + } + None => return Err(SpecificationError("dependency target required".into())), + }; + exact(id)?; + require( + kind != pb::EntityKind::Specification || id != value.id.as_ref(), + "redundant self-dependency", + )?; + require( + dependencies.insert((kind, id.map(|id| (&id.namespace, &id.slug, id.version)))), + "duplicate dependency", + )?; + } + let entity = Entity { + kind: Some(entity::Kind::Specification(value.clone())), + system: None, + }; + connections::validate_stored_connection_ids(&entity) + .map_err(|e| SpecificationError(e.to_string()))?; + require( + connections::connection_occurrences(&entity) + .map_err(|e| SpecificationError(e.to_string()))? + .len() + <= 4096, + "Specification association limit exceeded", + )?; + Ok(()) +} + +pub(crate) fn rewrite_exposure_selections(previous: &Entity, proposed: &mut Entity) { + let Some(prior) = spec(previous) else { + return; + }; + let Some(value) = spec_mut(proposed) else { + return; + }; + let changed: HashMap<_, _> = prior + .exposures + .iter() + .zip(&value.exposures) + .filter_map(|(old, new)| { + Some(( + old.connection_id.as_ref()?.value.clone(), + new.connection_id.clone()?, + )) + }) + .collect(); + for usage in &mut value.usages { + if let Some(replacement) = usage + .exposure_id + .as_ref() + .and_then(|id| changed.get(&id.value)) + { + usage.exposure_id = Some(replacement.clone()); + } + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/specification_refs.rs b/rsworkspace/crates/trogon-atlas-core/src/specification_refs.rs new file mode 100644 index 00000000..008ecbb8 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/specification_refs.rs @@ -0,0 +1,161 @@ +use trogon_atlas_proto::{ + contract_dependency, operation_binding, provider_target, schema_binding, schema_definition, + EntityKind, Id, ProviderTarget, Specification, +}; + +use crate::refs::OutboundRef; + +fn provider( + target: &mut ProviderTarget, + path: &str, + visit: &mut impl FnMut(EntityKind, &mut Option, String, bool), +) { + match target.target.as_mut() { + Some(provider_target::Target::Component(r)) => { + visit( + EntityKind::Component, + &mut r.id, + format!("{path}.component"), + false, + ); + } + Some(provider_target::Target::ExternalSystem(r)) => { + visit( + EntityKind::ExternalSystem, + &mut r.id, + format!("{path}.external_system"), + false, + ); + } + None => {} + } +} + +fn walk( + value: &mut Specification, + visit: &mut impl FnMut(EntityKind, &mut Option, String, bool), +) { + for (i, schema) in value.schemas.iter_mut().enumerate() { + if let Some(schema_definition::Target::Schema(r)) = + schema.definition.as_mut().and_then(|d| d.target.as_mut()) + { + visit( + EntityKind::Schema, + &mut r.id, + format!("schemas[{i}].definition.schema"), + false, + ); + } + } + for (i, message) in value.messages.iter_mut().enumerate() { + if let Some(r) = message.mapping.as_mut().and_then(|m| m.read_model.as_mut()) { + visit( + EntityKind::ReadModel, + &mut r.id, + format!("messages[{i}].mapping.read_model"), + false, + ); + } + for (j, binding) in message.schemas.iter_mut().enumerate() { + let path = format!("messages[{i}].schemas[{j}]"); + match binding.r#type.as_mut() { + Some(schema_binding::Type::Schema(r)) => visit( + EntityKind::Schema, + &mut r.id, + format!("{path}.schema"), + false, + ), + Some(schema_binding::Type::LibraryType(r)) => { + if let Some(library) = r.library.as_mut() { + visit( + EntityKind::TypeLibrary, + &mut library.id, + format!("{path}.library_type.library"), + false, + ); + } + } + Some(schema_binding::Type::ContractType(r)) => { + if let Some(owner) = r.schema.as_mut().and_then(|s| s.specification.as_mut()) { + visit( + EntityKind::Specification, + &mut owner.id, + format!("{path}.contract_type.schema.specification"), + true, + ); + } + } + _ => {} + } + } + } + for (i, operation) in value.operations.iter_mut().enumerate() { + if let Some(operation_binding::Kind::Channel(binding)) = + operation.binding.as_mut().and_then(|b| b.kind.as_mut()) + { + if let Some(r) = binding.channel.as_mut() { + visit( + EntityKind::Channel, + &mut r.id, + format!("operations[{i}].binding.channel.channel"), + false, + ); + } + } + } + for (i, exposure) in value.exposures.iter_mut().enumerate() { + if let Some(p) = exposure.provider.as_mut() { + provider(p, &format!("exposures[{i}].provider"), visit); + } + } + for (i, usage) in value.usages.iter_mut().enumerate() { + if let Some(p) = usage.caller.as_mut() { + provider(p, &format!("usages[{i}].caller"), visit); + } + } + for (i, dependency) in value.dependencies.iter_mut().enumerate() { + let path = format!("dependencies[{i}]"); + match dependency.target.as_mut() { + Some(contract_dependency::Target::Specification(r)) => visit( + EntityKind::Specification, + &mut r.id, + format!("{path}.specification"), + false, + ), + Some(contract_dependency::Target::TypeLibrary(r)) => visit( + EntityKind::TypeLibrary, + &mut r.id, + format!("{path}.type_library"), + false, + ), + None => {} + } + } +} + +pub(crate) fn outbound(value: &Specification, out: &mut Vec) { + walk(&mut value.clone(), &mut |kind, id, path, _| { + if let Some(id) = id { + out.push(OutboundRef { + to_kind: Some(kind), + to_id: id.clone(), + from_field: path, + }); + } + }); +} + +pub(crate) fn retarget( + value: &mut Specification, + kind: EntityKind, + from: &Id, + to: &Id, + rewritten: &mut Vec, +) { + walk(value, &mut |target_kind, id, path, pinned| { + if !pinned && target_kind == kind && id.as_ref() == Some(from) { + *id = Some(to.clone()); + rewritten.push(path); + } + }); +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/specification.rs b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs new file mode 100644 index 00000000..a15cad1a --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs @@ -0,0 +1,680 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use trogon_atlas_core::{ + connection_persistence::{prepare_entity, prepare_landing, prepare_resolution}, + connections::{connection_occurrences, resolve_connection}, + outbound_refs, retarget_refs, + specification::{ + content_digest, is_api, resolve_member, resolve_type, validate_specification, MemberId, + }, +}; +use trogon_atlas_proto::*; + +fn id(slug: &str) -> Id { + Id { + namespace: "contracts".into(), + slug: slug.into(), + version: 1, + } +} +fn name(value: &str) -> MemberName { + MemberName { + value: value.into(), + } +} +fn spec(entity: &Entity) -> &Specification { + let Some(entity::Kind::Specification(s)) = &entity.kind else { + panic!("expected Specification") + }; + s +} +fn spec_mut(entity: &mut Entity) -> &mut Specification { + let Some(entity::Kind::Specification(s)) = &mut entity.kind else { + panic!("expected Specification") + }; + s +} +fn member() -> ContractMemberId { + MemberId::new().into() +} + +fn fixture() -> Entity { + let artifact = Some(member()); + let schema = member(); + let message = Some(member()); + let operation = Some(member()); + let digest = content_digest("{\r\n \"type\": \"object\"\r\n}\r\n"); + let entity = Entity { + kind: Some(entity::Kind::Specification(Specification { + id: Some(id("orders")), + title: "Orders contract".into(), + artifacts: vec![ContractArtifact { + member_id: artifact.clone(), + name: Some(name("source")), + path: Some(ArtifactPath { + value: "contracts/orders.json".into(), + }), + format: Some(ContractFormat { + kind: Some(contract_format::Kind::Known( + contract_format::Known::JsonSchema as i32, + )), + }), + media_type: Some(MediaType { + value: "application/schema+json".into(), + }), + content: "{\r\n \"type\": \"object\"\r\n}\r\n".into(), + digest: Some(digest.clone()), + ..Default::default() + }], + schemas: vec![ContractSchema { + member_id: Some(schema.clone()), + name: Some(name("Order")), + definition: Some(SchemaDefinition { + target: Some(schema_definition::Target::Artifact( + LocalArtifactSelection { + artifact_id: artifact, + expected_digest: Some(digest), + }, + )), + selector: Some(OpaqueTypeSelector { + syntax: "json-pointer".into(), + value: "/Order".into(), + }), + ..Default::default() + }), + ..Default::default() + }], + messages: vec![ContractMessage { + member_id: message.clone(), + name: Some(name("receipt")), + schemas: vec![SchemaBinding { + role: schema_binding::Role::Payload as i32, + r#type: Some(schema_binding::Type::LocalSchema(schema)), + ..Default::default() + }], + mapping: Some(MessageReadModelMapping { + read_model: Some(ReadModelRef { + id: Some(id("receipt")), + }), + ..Default::default() + }), + ..Default::default() + }], + operations: vec![ContractOperation { + member_id: operation.clone(), + name: Some(name("submit")), + messages: vec![OperationMessage { + message_id: message, + role: operation_message::Role::Response as i32, + ..Default::default() + }], + ..Default::default() + }], + exposures: vec![ProviderExposure { + provider: Some(ProviderTarget { + target: Some(provider_target::Target::Component(ComponentRef { + id: Some(id("gateway")), + })), + }), + operation_id: operation.clone(), + ..Default::default() + }], + usages: vec![ApiUsage { + caller: Some(ProviderTarget { + target: Some(provider_target::Target::Component(ComponentRef { + id: Some(id("checkout")), + })), + }), + operation_id: operation, + ..Default::default() + }], + ..Default::default() + })), + system: None, + }; + let mut entity = prepare_entity(None, &entity).unwrap(); + let exposure_id = spec(&entity).exposures[0].connection_id.clone(); + spec_mut(&mut entity).usages[0].exposure_id = exposure_id; + spec_mut(&mut entity).usages[0].connection_id = None; + let entity = prepare_entity(None, &entity).unwrap(); + validate_specification(spec(&entity)).unwrap(); + entity +} + +fn type_pin(entity: &Entity) -> ContractTypeRef { + let s = spec(entity); + ContractTypeRef { + schema: Some(ContractMemberRef { + specification: Some(SpecificationRef { id: s.id.clone() }), + owner_uid: entity.system.as_ref().unwrap().uid.clone(), + kind: ContractMemberKind::Schema as i32, + member_id: s.schemas[0].member_id.clone(), + expected_digest: None, + }), + expected_artifact_id: s.artifacts[0].member_id.clone(), + expected_digest: s.artifacts[0].digest.clone(), + expected_selector: s.schemas[0].definition.as_ref().unwrap().selector.clone(), + } +} + +#[test] +fn generated_contract_revision_and_canonical_kind_are_complete() { + assert_eq!( + ( + CONTRACT_REVISION, + MIN_SERVER_CONTRACT_REVISION, + MIN_CLIENT_CONTRACT_REVISION + ), + (11, 11, 11) + ); + assert_eq!( + canonical::parse_kind("specification"), + Some(EntityKind::Specification) + ); + assert!(canonical::ALL_KINDS.contains(&EntityKind::Specification)); +} + +#[test] +fn local_graph_is_native_and_classification_is_derived() { + let entity = fixture(); + assert!(is_api(spec(&entity))); + assert_eq!(connection_occurrences(&entity).unwrap().len(), 6); + for occurrence in connection_occurrences(&entity).unwrap() { + let selected = ConnectionRef { + owner: Some(EntityRef { + kind: EntityKind::Specification as i32, + id: spec(&entity).id.clone(), + }), + owner_uid: entity.system.as_ref().unwrap().uid.clone(), + id: occurrence.id.map(Into::into), + }; + assert_eq!(resolve_connection(&entity, &selected).unwrap(), occurrence); + } + let mut undeclared = entity.clone(); + spec_mut(&mut undeclared).exposures.clear(); + spec_mut(&mut undeclared).usages[0].exposure_id = None; + spec_mut(&mut undeclared).usages[0].connection_id = None; + let undeclared = prepare_entity(Some(&entity), &undeclared).unwrap(); + validate_specification(spec(&undeclared)).unwrap(); + assert!(!is_api(spec(&undeclared))); + let mut schema_only = undeclared; + let s = spec_mut(&mut schema_only); + s.usages.clear(); + s.operations.clear(); + s.messages.clear(); + validate_specification(s).unwrap(); +} + +#[test] +fn member_identity_survives_edits_and_is_bounded_to_active_state() { + let original = fixture(); + let mut renamed = original.clone(); + spec_mut(&mut renamed).messages[0].name = Some(name("renamed")); + spec_mut(&mut renamed).messages[0].doc = Some("documentation".into()); + let renamed = prepare_entity(Some(&original), &renamed).unwrap(); + assert_eq!( + spec(&renamed).messages[0].member_id, + spec(&original).messages[0].member_id + ); + let mut removed = renamed.clone(); + let s = spec_mut(&mut removed); + s.messages.clear(); + s.operations.clear(); + s.exposures.clear(); + s.usages.clear(); + let removed = prepare_entity(Some(&renamed), &removed).unwrap(); + assert!(prepare_entity(Some(&removed), &renamed).is_err()); + let mut addition = removed.clone(); + spec_mut(&mut addition).messages.push(ContractMessage { + name: Some(name("renamed")), + ..Default::default() + }); + let added = prepare_entity(Some(&removed), &addition).unwrap(); + assert_ne!( + spec(&added).messages[0].member_id, + spec(&renamed).messages[0].member_id + ); + validate_specification(spec(&added)).unwrap(); +} + +#[test] +fn immutable_artifacts_preserve_exact_utf8_bytes_and_pins() { + let original = fixture(); + let pin = type_pin(&original); + resolve_type(&original, &pin).unwrap(); + let mut changed = original.clone(); + spec_mut(&mut changed).artifacts[0].content = "different".into(); + assert!(validate_specification(spec(&changed)).is_err()); + spec_mut(&mut changed).artifacts[0].digest = Some(content_digest("different")); + assert!(prepare_entity(Some(&original), &changed).is_err()); + assert_ne!(content_digest("a\r\n"), content_digest("a\n")); + for mutation in 0..5 { + let mut broken = pin.clone(); + match mutation { + 0 => broken.expected_selector = None, + 1 => broken.expected_selector.as_mut().unwrap().value = "/different".into(), + 2 => broken.expected_digest = Some(content_digest("different")), + 3 => broken.expected_artifact_id = Some(member()), + _ => broken.schema.as_mut().unwrap().owner_uid = "different".into(), + } + assert!(resolve_type(&original, &broken).is_err()); + } + let mut wrong_kind = pin.schema.unwrap(); + wrong_kind.kind = ContractMemberKind::Artifact as i32; + assert!(resolve_member(&original, &wrong_kind).is_err()); +} + +#[test] +fn all_association_endpoints_and_containing_members_are_identity_significant() { + let original = fixture(); + for mutation in 0..7 { + let mut changed = original.clone(); + let s = spec_mut(&mut changed); + match mutation { + 0 => s.schemas[0].definition.as_mut().unwrap().selector = None, + 1 => s.messages[0].schemas[0].role = schema_binding::Role::Headers as i32, + 2 => s.operations[0].messages[0].role = operation_message::Role::Request as i32, + 3 => s.usages[0].exposure_id = None, + 4 => s.exposures[0].provider = s.usages[0].caller.clone(), + 5 => { + s.messages[0] + .mapping + .as_mut() + .unwrap() + .read_model + .as_mut() + .unwrap() + .id = Some(id("different")); + } + _ => { + let mut duplicate = s.messages[0].clone(); + duplicate.member_id = None; + duplicate.name = Some(name("different")); + s.messages.clear(); + s.messages.push(duplicate); + } + } + assert!( + prepare_entity(Some(&original), &changed).is_err(), + "mutation {mutation}" + ); + } + let mut docs = original.clone(); + spec_mut(&mut docs).schemas[0].doc = Some("schema documentation".into()); + spec_mut(&mut docs).operations[0].messages[0].doc = Some("occurrence documentation".into()); + let edited = prepare_entity(Some(&original), &docs).unwrap(); + assert_eq!( + connection_occurrences(&original).unwrap(), + connection_occurrences(&edited).unwrap() + ); +} + +#[test] +fn member_removal_requires_local_selection_repair_and_duplicate_ids_fail() { + let entity = fixture(); + for mutation in 0..6 { + let mut changed = entity.clone(); + let s = spec_mut(&mut changed); + match mutation { + 0 => s.schemas.clear(), + 1 => s.artifacts.clear(), + 2 => s.exposures.clear(), + 3 => s.messages[0].member_id = s.schemas[0].member_id.clone(), + 4 => { + s.messages[0].mapping.as_mut().unwrap().connection_id = s.schemas[0] + .definition + .as_ref() + .unwrap() + .connection_id + .clone(); + } + _ => s.operations[0].messages[0].message_id = s.schemas[0].member_id.clone(), + } + assert!(validate_specification(s).is_err(), "mutation {mutation}"); + } +} + +#[test] +fn concurrent_member_removal_blocks_land_and_explicit_restore_rewrites_local_graph() { + let base = fixture(); + let mut opposing = base.clone(); + let s = spec_mut(&mut opposing); + s.schemas.clear(); + s.messages.clear(); + s.operations.clear(); + s.exposures.clear(); + s.usages.clear(); + let opposing = prepare_entity(Some(&base), &opposing).unwrap(); + assert!(prepare_landing(Some(&base), Some(&opposing), &base).is_err()); + let restored = prepare_resolution(Some(&base), Some(&opposing), &base).unwrap(); + validate_specification(spec(&restored)).unwrap(); + assert_ne!( + spec(&base).schemas[0].member_id, + spec(&restored).schemas[0].member_id + ); + assert_ne!( + spec(&base).operations[0].member_id, + spec(&restored).operations[0].member_id + ); + assert_eq!( + spec(&restored).usages[0].exposure_id, + spec(&restored).exposures[0].connection_id + ); + assert!(prepare_landing(Some(&opposing), Some(&opposing), &restored).is_ok()); + assert!(resolve_type(&restored, &type_pin(&base)).is_err()); +} + +#[test] +fn owner_clone_keeps_local_ids_and_regenerates_incarnation() { + let original = fixture(); + let cloned = prepare_entity(None, &original).unwrap(); + assert_ne!(original.system, cloned.system); + assert_eq!(spec(&original), spec(&cloned)); + assert!(resolve_type(&cloned, &type_pin(&original)).is_err()); + let restored = prepare_resolution(Some(&original), None, &original).unwrap(); + assert_eq!(restored, original); +} + +#[test] +fn every_nested_external_target_is_discovered_but_pins_are_not_retargeted() { + let mut entity = fixture(); + let pin = type_pin(&entity); + let s = spec_mut(&mut entity); + s.messages[0].schemas.extend([ + SchemaBinding { + r#type: Some(schema_binding::Type::Schema(SchemaRef { + id: Some(id("native")), + })), + ..Default::default() + }, + SchemaBinding { + r#type: Some(schema_binding::Type::LibraryType(TypeLibraryTypeRef { + library: Some(TypeLibraryRef { + id: Some(id("types")), + }), + message: Some(QualifiedProtoName { + value: "types.Message".into(), + }), + })), + ..Default::default() + }, + SchemaBinding { + r#type: Some(schema_binding::Type::ContractType(pin)), + ..Default::default() + }, + ]); + s.dependencies.push(ContractDependency { + target: Some(contract_dependency::Target::Specification( + SpecificationRef { + id: Some(id("dependency")), + }, + )), + ..Default::default() + }); + s.dependencies.push(ContractDependency { + target: Some(contract_dependency::Target::TypeLibrary(TypeLibraryRef { + id: Some(id("types")), + })), + ..Default::default() + }); + s.operations[0].binding = Some(OperationBinding { + kind: Some(operation_binding::Kind::Channel(ChannelOperationBinding { + channel: Some(ChannelRef { + id: Some(id("transport")), + }), + ..Default::default() + })), + }); + let refs = outbound_refs(&entity); + assert_eq!(refs.len(), 9); + for kind in [ + EntityKind::ReadModel, + EntityKind::Schema, + EntityKind::TypeLibrary, + EntityKind::Specification, + EntityKind::Channel, + EntityKind::Component, + ] { + assert!(refs.iter().any(|r| r.to_kind == Some(kind)), "{kind:?}"); + } + assert!(refs + .iter() + .all(|r| r.to_kind != Some(EntityKind::Command) && r.to_kind != Some(EntityKind::Event))); + assert_eq!( + retarget_refs( + &mut entity, + EntityKind::Specification, + &id("orders"), + &id("replacement") + ), + Vec::::new() + ); + assert_eq!( + retarget_refs( + &mut entity, + EntityKind::TypeLibrary, + &id("types"), + &id("replacement") + ) + .len(), + 2 + ); + assert_eq!( + retarget_refs( + &mut entity, + EntityKind::Channel, + &id("transport"), + &id("replacement") + ) + .len(), + 1 + ); +} + +#[test] +fn idless_noop_manifest_keeps_authoritative_member_and_connection_ids() { + let original = fixture(); + let mut manifest = original.clone(); + let s = spec_mut(&mut manifest); + for a in &mut s.artifacts { + a.member_id = None; + } + for a in &mut s.schemas { + a.member_id = None; + a.definition.as_mut().unwrap().connection_id = None; + } + for a in &mut s.messages { + a.member_id = None; + a.mapping.as_mut().unwrap().connection_id = None; + for b in &mut a.schemas { + b.connection_id = None; + } + } + for a in &mut s.operations { + a.member_id = None; + for m in &mut a.messages { + m.connection_id = None; + } + } + for a in &mut s.exposures { + a.connection_id = None; + } + for a in &mut s.usages { + a.connection_id = None; + } + assert_eq!( + prepare_entity(Some(&original), &manifest).unwrap(), + original + ); +} + +#[test] +fn edited_idless_manifest_cannot_strip_retained_member_identity() { + let entity = prepare_entity( + None, + &Entity { + kind: Some(entity::Kind::Specification(Specification { + id: Some(id("standalone")), + title: "Standalone".into(), + messages: vec![ContractMessage { + name: Some(name("message")), + ..Default::default() + }], + ..Default::default() + })), + system: None, + }, + ) + .unwrap(); + let mut manifest = entity.clone(); + spec_mut(&mut manifest).messages[0].member_id = None; + assert_eq!(prepare_entity(Some(&entity), &manifest).unwrap(), entity); + spec_mut(&mut manifest).messages[0].doc = Some("edited".into()); + assert!(prepare_entity(Some(&entity), &manifest).is_err()); + spec_mut(&mut manifest).messages[0].name = Some(name("renamed")); + assert!(prepare_entity(Some(&entity), &manifest).is_err()); +} + +#[test] +fn changed_schema_definition_preserves_local_bindings_but_invalidates_foreign_pin() { + let original = fixture(); + let pin = type_pin(&original); + let mut changed = original.clone(); + let s = spec_mut(&mut changed); + s.schemas[0].definition.as_mut().unwrap().selector = None; + s.schemas[0].definition.as_mut().unwrap().connection_id = None; + let changed = prepare_entity(Some(&original), &changed).unwrap(); + validate_specification(spec(&changed)).unwrap(); + assert_eq!( + spec(&changed).messages[0].schemas, + spec(&original).messages[0].schemas + ); + assert!(resolve_type(&changed, &pin).is_err()); +} + +#[test] +fn limits_and_transport_declarations_are_admitted_without_semantic_document_parsing() { + let entity = fixture(); + for mutation in 0..8 { + let mut changed = entity.clone(); + let s = spec_mut(&mut changed); + match mutation { + 0 => s.artifacts[0].path.as_mut().unwrap().value = "../orders".into(), + 1 => s.artifacts[0].media_type.as_mut().unwrap().value = "non-ascii-λ".into(), + 2 => s.artifacts[0].content = "x".repeat(256 * 1024 + 1), + 3 => { + s.artifacts[0].format = Some(ContractFormat { + kind: Some(contract_format::Kind::Known(999)), + }); + } + 4 => s.messages[0].schemas[0].role = 999, + 5 => s.operations[0].messages[0].role = 0, + 6 => { + s.operations[0].binding = Some(OperationBinding { + kind: Some(operation_binding::Kind::Http(HttpOperationBinding { + method: "get".into(), + path: "/orders".into(), + response_status_codes: vec![200], + })), + }); + } + _ => { + s.operations[0].binding = Some(OperationBinding { + kind: Some(operation_binding::Kind::Http(HttpOperationBinding { + method: "GET".into(), + path: "/orders".into(), + response_status_codes: vec![200, 200], + })), + }); + } + } + assert!(validate_specification(s).is_err(), "mutation {mutation}"); + } + let mut opaque = entity; + let s = spec_mut(&mut opaque); + s.artifacts[0].format = Some(ContractFormat { + kind: Some(contract_format::Kind::Other("custom format".into())), + }); + validate_specification(s).unwrap(); +} + +#[test] +fn missing_persisted_member_or_association_ids_never_bypass_noop_validation() { + let original = fixture(); + let mut broken = original.clone(); + spec_mut(&mut broken).messages[0].member_id = None; + assert!(prepare_entity(Some(&broken), &broken).is_err()); + let mut broken = original; + spec_mut(&mut broken).usages[0].connection_id = None; + assert!(prepare_entity(Some(&broken), &broken).is_err()); +} + +#[test] +fn strict_member_uuid_validation_and_new_owner_assignment() { + for value in [ + "", + "00000000-0000-0000-0000-000000000000", + "019382AB-4455-7666-8888-123456789012", + "019382ab445576668888123456789012", + "arbitrary", + ] { + assert!(value.parse::().is_err()); + } + let accepted = MemberId::new(); + assert_eq!(accepted.to_string().parse::().unwrap(), accepted); + let input = Entity { + kind: Some(entity::Kind::Specification(Specification { + id: Some(id("fresh")), + title: "Fresh".into(), + messages: vec![ContractMessage { + name: Some(name("unmapped")), + ..Default::default() + }], + ..Default::default() + })), + system: None, + }; + let created = prepare_entity(None, &input).unwrap(); + assert!(spec(&created).messages[0].member_id.is_some()); + validate_specification(spec(&created)).unwrap(); + let repeated = prepare_entity(None, &input).unwrap(); + assert_ne!( + spec(&created).messages[0].member_id, + spec(&repeated).messages[0].member_id + ); +} + +#[test] +fn resolver_rejects_corrupt_stored_member_identity_sets() { + let original = fixture(); + let pin = type_pin(&original); + for mutation in 0..3 { + let mut corrupt = original.clone(); + let s = spec_mut(&mut corrupt); + match mutation { + 0 => s.schemas.push(s.schemas[0].clone()), + 1 => s.messages[0].member_id = None, + _ => s.messages[0].member_id = s.schemas[0].member_id.clone(), + } + assert!( + resolve_member(&corrupt, pin.schema.as_ref().unwrap()).is_err(), + "mutation {mutation}" + ); + assert!(resolve_type(&corrupt, &pin).is_err(), "mutation {mutation}"); + } +} + +#[test] +fn same_owner_type_pins_require_local_schema_selection() { + let mut original = fixture(); + let pin = type_pin(&original); + spec_mut(&mut original).messages[0] + .schemas + .push(SchemaBinding { + role: schema_binding::Role::Payload as i32, + r#type: Some(schema_binding::Type::ContractType(pin)), + ..Default::default() + }); + let prepared = prepare_entity(None, &original).unwrap(); + assert!(validate_specification(spec(&prepared)).is_err()); +} diff --git a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs index 68638ca1..e47c1b2e 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs @@ -51,6 +51,14 @@ pub const SERVER_INSTRUCTIONS: &str = "trogon-atlas MCP: tools for the trogonatl which role, namespaces, or owner you are authenticated as; the tools listed here \ are already filtered to what that role can call.\n\ \n\ + SPECIFICATION AUTHORING: use the native specification entity for retained contract artifacts, \ + schemas, messages, operations, provider exposures and usages. API classification is derived \ + from operations and exposures selecting them. Integration messages map only to ReadModels; \ + request/response/notification roles do not create Commands or Events. Preserve authoritative \ + member IDs, connection IDs, exact artifact text and SHA-256 digests on edits and manifest \ + round trips. Add replacement artifact members to change bytes. Opaque selectors remain \ + unverified; no format importer, remote fetch or semantic validation is implied.\n\ + \n\ STORYBOARD AUTHORING RULES (validated; violations are rejected or flagged):\n\ 1. A storyboard is a TEMPORAL sequence: each slice is one moment. A read-model \ slice lists only the events that just happened at that moment, never the type's \ @@ -1794,7 +1802,7 @@ impl McpServer { } #[tool( - description = "Fetch a single entity as proto3 JSON (editable without protobuf tooling). The canonical JSON workflow is get_entity_json -> edit -> put_entity_json.", + description = "Fetch a single entity as proto3 JSON, including Specification artifact content, member IDs and association IDs. The canonical JSON workflow is get_entity_json -> edit -> put_entity_json; preserve returned identities and retained bytes.", annotations( read_only_hint = true, destructive_hint = false, @@ -2061,7 +2069,7 @@ impl McpServer { return Err(rmcp::ErrorData::invalid_params( format!("unknown op {other}"), None, - )) + )); } }; ops.push(pb::BatchMutateOp { op: Some(inner) }); @@ -2630,7 +2638,7 @@ impl McpServer { } #[tool( - description = "Export every entity in a namespace back into manifest YAML (same format as trogon-atlas export), one file per entity kind.", + description = "Export every entity in a namespace back into native manifest YAML, one file per entity kind. Specification exports retain exact artifact text, digests, member IDs and association IDs for apply_manifests round trips.", annotations( read_only_hint = true, destructive_hint = false, @@ -2871,7 +2879,7 @@ impl McpServer { {other:?}" ), None, - )) + )); } }; let expected = BranchEntryState::from_wire(&pb::BranchEntryState { @@ -2993,6 +3001,8 @@ mod tests { ("Channel", pb::EntityKind::Channel), ("data_store", pb::EntityKind::DataStore), ("DataStore", pb::EntityKind::DataStore), + ("specification", pb::EntityKind::Specification), + ("Specification", pb::EntityKind::Specification), ] { assert_eq!(parse_kind(s).unwrap(), expected, "kind {s}"); } diff --git a/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs b/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs index 86e5fe9e..dad0adb6 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs @@ -17,7 +17,7 @@ struct EntitySpecDe { #[serde(deny_unknown_fields)] pub struct EntitySpec { #[schemars( - description = "Entity kind in snake_case, e.g. event, read_model or event_model. Call list_entity_kinds for every kind." + description = "Entity kind in snake_case, e.g. event, read_model, event_model or specification. Call list_entity_kinds for every kind." )] pub kind: String, pub namespace: String, @@ -1100,10 +1100,10 @@ mod deny_unknown_params_tests { })); if let Ok(p) = parsed { assert_ne!( - p.project_version, - Some(0), - "omitted project_version silently became 0; agents will look up a non-existent @0 id" - ); + p.project_version, + Some(0), + "omitted project_version silently became 0; agents will look up a non-existent @0 id" + ); } } diff --git a/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs b/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs index a4e7ad55..6299204a 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs @@ -1546,3 +1546,140 @@ async fn architecture_resources_roundtrip_through_mcp_dispatch() { } service.cancel().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn specification_mcp_roundtrip_preserves_stored_members_associations_and_artifact_bytes() { + let (client, service, endpoint) = start_mcp_client().await; + let ns = "mcp-specification"; + for resource in [ + serde_json::json!({"readModel": {"id": {"namespace": ns, "slug": "receipt", "version": "2"}, "title": "Receipt"}}), + serde_json::json!({"component": {"id": {"namespace": ns, "slug": "gateway", "version": "1"}, "title": "Gateway"}}), + ] { + call_ok( + &client, + "put_entity_json", + put_entity_json_args(&resource.to_string()), + ) + .await; + } + let artifact_id = "11111111-1111-4111-8111-111111111111"; + let schema_id = "22222222-2222-4222-8222-222222222222"; + let message_id = "33333333-3333-4333-8333-333333333333"; + let operation_id = "44444444-4444-4444-8444-444444444444"; + let content = "{\"type\":\"object\"}\r\n"; + let digest = "6f8f8b83ea6711930c12c3c32001521787644432b59f31251b32aefe9f0d494d"; + let spec = serde_json::json!({ + "title": "Order contract", + "artifacts": [{"memberId": {"value": artifact_id}, "name": {"value": "Order source"}, + "path": {"value": "contracts/order.json"}, "format": {"known": "KNOWN_JSON_SCHEMA"}, + "formatVersion": {"value": "2020-12"}, "mediaType": {"value": "application/schema+json"}, + "content": content, "digest": {"sha256": digest}}], + "schemas": [{"memberId": {"value": schema_id}, "name": {"value": "Order shape"}, + "definition": {"artifact": {"artifactId": {"value": artifact_id}, "expectedDigest": {"sha256": digest}}, + "selector": {"syntax": "json-pointer", "value": "/$defs/Order"}}}], + "messages": [{"memberId": {"value": message_id}, "name": {"value": "Order message"}, + "schemas": [{"role": "ROLE_PAYLOAD", "localSchema": {"value": schema_id}}], + "mapping": {"readModel": {"id": {"namespace": ns, "slug": "receipt", "version": "2"}}}}], + "operations": [{"memberId": {"value": operation_id}, "name": {"value": "Submit"}, + "binding": {"http": {"method": "POST", "path": "/orders", "responseStatusCodes": [201]}}, + "messages": [ + {"role": "ROLE_REQUEST", "messageId": {"value": message_id}}, + {"role": "ROLE_RESPONSE", "messageId": {"value": message_id}}]}], + "exposures": [{"provider": {"component": {"id": {"namespace": ns, "slug": "gateway", "version": "1"}}}, + "operationId": {"value": operation_id}}], + "usages": [{"caller": {"component": {"id": {"namespace": ns, "slug": "gateway", "version": "1"}}}, + "operationId": {"value": operation_id}}] + }); + + let mut resource = spec; + resource["id"] = serde_json::json!({"namespace": ns, "slug": "orders", "version": "1"}); + let resource = serde_json::json!({"specification": resource}); + call_ok( + &client, + "put_entity_json", + put_entity_json_args(&resource.to_string()), + ) + .await; + let get_args = args_obj(&[ + ("kind", serde_json::json!("Specification")), + ("namespace", serde_json::json!(ns)), + ("slug", serde_json::json!("orders")), + ("version", serde_json::json!(1)), + ]); + let got = call_ok(&client, "get_entity_json", get_args.clone()).await; + let before = call_tool_result_text(&got); + let contract = &before["json"]["specification"]; + assert_eq!( + contract["artifacts"][0]["content"] + .as_str() + .unwrap() + .as_bytes(), + content.as_bytes() + ); + assert_eq!(contract["artifacts"][0]["digest"]["sha256"], digest); + assert_eq!( + contract["operations"][0]["messages"][0]["messageId"]["value"], + message_id + ); + assert_eq!( + contract["operations"][0]["messages"][1]["messageId"]["value"], + message_id + ); + let mut ids = std::collections::HashSet::new(); + for occurrence in [ + &contract["schemas"][0]["definition"], + &contract["messages"][0]["schemas"][0], + &contract["messages"][0]["mapping"], + &contract["operations"][0]["messages"][0], + &contract["operations"][0]["messages"][1], + &contract["exposures"][0], + &contract["usages"][0], + ] { + let id = occurrence["connectionId"]["value"].as_str().unwrap(); + assert_ne!(id, ""); + assert!(ids.insert(id)); + } + let exported = call_ok( + &client, + "export_namespace", + args_obj(&[("namespace", serde_json::json!(ns))]), + ) + .await; + let exported = call_tool_result_text(&exported); + assert_eq!(exported["skipped"], serde_json::json!([])); + let yaml = exported["files"] + .as_object() + .unwrap() + .values() + .map(|v| v.as_str().unwrap()) + .collect::>() + .join("\n---\n"); + call_ok( + &client, + "apply_manifests", + args_obj(&[("manifests_yaml", serde_json::json!(yaml))]), + ) + .await; + let after = call_ok(&client, "get_entity_json", get_args).await; + assert_eq!(before, call_tool_result_text(&after)); + let mut grpc = grpc_client(&endpoint).await; + let stored = grpc + .get_entity(pb::GetEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(id(ns, "orders", 1)), + }) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + let Some(pb::entity::Kind::Specification(contract)) = stored.kind else { + panic!("expected Specification") + }; + assert_eq!(contract.artifacts[0].content.as_bytes(), content.as_bytes()); + assert_eq!( + contract.artifacts[0].member_id.as_ref().unwrap().value, + artifact_id + ); + service.cancel().await.unwrap(); +} diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/descriptor.binpb b/rsworkspace/crates/trogon-atlas-proto/src/gen/descriptor.binpb index f6597011..3ca9835c 100644 Binary files a/rsworkspace/crates/trogon-atlas-proto/src/gen/descriptor.binpb and b/rsworkspace/crates/trogon-atlas-proto/src/gen/descriptor.binpb differ diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs index 5a2fc63d..0c2d5210 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs @@ -9,6 +9,16 @@ /// Where a connection between entities can carry data (slice endpoints, /// storyboard entry/outcome), the schema uses Edge messages instead. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SpecificationRef { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SchemaRef { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ChannelRef { #[prost(message, optional, tag="1")] pub id: ::core::option::Option, @@ -134,6 +144,7 @@ pub enum EntityKind { TypeLibrary = 29, Channel = 30, DataStore = 31, + Specification = 32, } impl EntityKind { /// String value of the enum field names used in the ProtoBuf definition. @@ -174,6 +185,7 @@ impl EntityKind { Self::TypeLibrary => "ENTITY_KIND_TYPE_LIBRARY", Self::Channel => "ENTITY_KIND_CHANNEL", Self::DataStore => "ENTITY_KIND_DATA_STORE", + Self::Specification => "ENTITY_KIND_SPECIFICATION", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -211,6 +223,7 @@ impl EntityKind { "ENTITY_KIND_TYPE_LIBRARY" => Some(Self::TypeLibrary), "ENTITY_KIND_CHANNEL" => Some(Self::Channel), "ENTITY_KIND_DATA_STORE" => Some(Self::DataStore), + "ENTITY_KIND_SPECIFICATION" => Some(Self::Specification), _ => None, } } @@ -3036,6 +3049,539 @@ pub mod data_store { } } } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractMemberId { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MemberName { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContentDigest { + #[prost(string, tag="1")] + pub sha256: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArtifactPath { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MediaType { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FormatVersion { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct QualifiedProtoName { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractFormat { + #[prost(oneof="contract_format::Kind", tags="1, 2")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `ContractFormat`. +pub mod contract_format { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Known { + Unspecified = 0, + Openapi = 1, + Asyncapi = 2, + Graphql = 3, + JsonSchema = 4, + Avro = 5, + Protobuf = 6, + } + impl Known { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "KNOWN_UNSPECIFIED", + Self::Openapi => "KNOWN_OPENAPI", + Self::Asyncapi => "KNOWN_ASYNCAPI", + Self::Graphql => "KNOWN_GRAPHQL", + Self::JsonSchema => "KNOWN_JSON_SCHEMA", + Self::Avro => "KNOWN_AVRO", + Self::Protobuf => "KNOWN_PROTOBUF", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "KNOWN_UNSPECIFIED" => Some(Self::Unspecified), + "KNOWN_OPENAPI" => Some(Self::Openapi), + "KNOWN_ASYNCAPI" => Some(Self::Asyncapi), + "KNOWN_GRAPHQL" => Some(Self::Graphql), + "KNOWN_JSON_SCHEMA" => Some(Self::JsonSchema), + "KNOWN_AVRO" => Some(Self::Avro), + "KNOWN_PROTOBUF" => Some(Self::Protobuf), + _ => None, + } + } + } + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Kind { + #[prost(enumeration="Known", tag="1")] + Known(i32), + #[prost(string, tag="2")] + Other(::prost::alloc::string::String), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OpaqueTypeSelector { + #[prost(string, tag="1")] + pub syntax: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractMemberRef { + #[prost(message, optional, tag="1")] + pub specification: ::core::option::Option, + #[prost(string, tag="2")] + pub owner_uid: ::prost::alloc::string::String, + #[prost(enumeration="ContractMemberKind", tag="3")] + pub kind: i32, + #[prost(message, optional, tag="4")] + pub member_id: ::core::option::Option, + #[prost(message, optional, tag="5")] + pub expected_digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractTypeRef { + #[prost(message, optional, tag="1")] + pub schema: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub expected_artifact_id: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub expected_digest: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub expected_selector: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractArtifact { + #[prost(message, optional, tag="1")] + pub member_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub name: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag="4")] + pub path: ::core::option::Option, + #[prost(message, optional, tag="5")] + pub format: ::core::option::Option, + #[prost(message, optional, tag="6")] + pub format_version: ::core::option::Option, + #[prost(message, optional, tag="7")] + pub media_type: ::core::option::Option, + #[prost(string, tag="8")] + pub content: ::prost::alloc::string::String, + #[prost(message, optional, tag="9")] + pub digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LocalArtifactSelection { + #[prost(message, optional, tag="1")] + pub artifact_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub expected_digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SchemaDefinition { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub selector: ::core::option::Option, + #[prost(oneof="schema_definition::Target", tags="2, 3")] + pub target: ::core::option::Option, +} +/// Nested message and enum types in `SchemaDefinition`. +pub mod schema_definition { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Target { + #[prost(message, tag="2")] + Artifact(super::LocalArtifactSelection), + #[prost(message, tag="3")] + Schema(super::SchemaRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractSchema { + #[prost(message, optional, tag="1")] + pub member_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub name: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag="4")] + pub definition: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TypeLibraryTypeRef { + #[prost(message, optional, tag="1")] + pub library: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub message: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SchemaBinding { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(enumeration="schema_binding::Role", tag="2")] + pub role: i32, + #[prost(string, optional, tag="7")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(oneof="schema_binding::Type", tags="3, 4, 5, 6")] + pub r#type: ::core::option::Option, +} +/// Nested message and enum types in `SchemaBinding`. +pub mod schema_binding { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Role { + Unspecified = 0, + Payload = 1, + Headers = 2, + } + impl Role { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "ROLE_UNSPECIFIED", + Self::Payload => "ROLE_PAYLOAD", + Self::Headers => "ROLE_HEADERS", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "ROLE_UNSPECIFIED" => Some(Self::Unspecified), + "ROLE_PAYLOAD" => Some(Self::Payload), + "ROLE_HEADERS" => Some(Self::Headers), + _ => None, + } + } + } + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Type { + #[prost(message, tag="3")] + LocalSchema(super::ContractMemberId), + #[prost(message, tag="4")] + ContractType(super::ContractTypeRef), + #[prost(message, tag="5")] + Schema(super::SchemaRef), + #[prost(message, tag="6")] + LibraryType(super::TypeLibraryTypeRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MessageReadModelMapping { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub read_model: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContractMessage { + #[prost(message, optional, tag="1")] + pub member_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub name: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub schemas: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="5")] + pub mapping: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OperationMessage { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub message_id: ::core::option::Option, + #[prost(enumeration="operation_message::Role", tag="3")] + pub role: i32, + #[prost(string, optional, tag="4")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, +} +/// Nested message and enum types in `OperationMessage`. +pub mod operation_message { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Role { + Unspecified = 0, + Request = 1, + Response = 2, + Notification = 3, + } + impl Role { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "ROLE_UNSPECIFIED", + Self::Request => "ROLE_REQUEST", + Self::Response => "ROLE_RESPONSE", + Self::Notification => "ROLE_NOTIFICATION", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "ROLE_UNSPECIFIED" => Some(Self::Unspecified), + "ROLE_REQUEST" => Some(Self::Request), + "ROLE_RESPONSE" => Some(Self::Response), + "ROLE_NOTIFICATION" => Some(Self::Notification), + _ => None, + } + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct HttpOperationBinding { + #[prost(string, tag="1")] + pub method: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub path: ::prost::alloc::string::String, + #[prost(uint32, repeated, tag="3")] + pub response_status_codes: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RpcOperationBinding { + #[prost(string, tag="1")] + pub service: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub method: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ChannelOperationBinding { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub channel: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GraphqlOperationBinding { + #[prost(enumeration="graphql_operation_binding::Kind", tag="1")] + pub kind: i32, + #[prost(string, tag="2")] + pub field: ::prost::alloc::string::String, +} +/// Nested message and enum types in `GraphqlOperationBinding`. +pub mod graphql_operation_binding { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Kind { + Unspecified = 0, + Query = 1, + Mutation = 2, + Subscription = 3, + } + impl Kind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "KIND_UNSPECIFIED", + Self::Query => "KIND_QUERY", + Self::Mutation => "KIND_MUTATION", + Self::Subscription => "KIND_SUBSCRIPTION", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "KIND_UNSPECIFIED" => Some(Self::Unspecified), + "KIND_QUERY" => Some(Self::Query), + "KIND_MUTATION" => Some(Self::Mutation), + "KIND_SUBSCRIPTION" => Some(Self::Subscription), + _ => None, + } + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OtherOperationBinding { + #[prost(message, optional, tag="1")] + pub protocol: ::core::option::Option, + #[prost(string, tag="2")] + pub description: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OperationBinding { + #[prost(oneof="operation_binding::Kind", tags="1, 2, 3, 4, 5")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `OperationBinding`. +pub mod operation_binding { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Kind { + #[prost(message, tag="1")] + Http(super::HttpOperationBinding), + #[prost(message, tag="2")] + Rpc(super::RpcOperationBinding), + #[prost(message, tag="3")] + Channel(super::ChannelOperationBinding), + #[prost(message, tag="4")] + Graphql(super::GraphqlOperationBinding), + #[prost(message, tag="5")] + Other(super::OtherOperationBinding), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContractOperation { + #[prost(message, optional, tag="1")] + pub member_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub name: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag="4")] + pub binding: ::core::option::Option, + #[prost(message, repeated, tag="5")] + pub messages: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ProviderTarget { + #[prost(oneof="provider_target::Target", tags="1, 2")] + pub target: ::core::option::Option, +} +/// Nested message and enum types in `ProviderTarget`. +pub mod provider_target { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Target { + #[prost(message, tag="1")] + Component(super::ComponentRef), + #[prost(message, tag="2")] + ExternalSystem(super::ExternalSystemRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ProviderExposure { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub provider: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub operation_id: ::core::option::Option, + #[prost(string, optional, tag="4")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ApiUsage { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub caller: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub operation_id: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub exposure_id: ::core::option::Option, + #[prost(string, optional, tag="5")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContractDependency { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(string, optional, tag="4")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(oneof="contract_dependency::Target", tags="2, 3")] + pub target: ::core::option::Option, +} +/// Nested message and enum types in `ContractDependency`. +pub mod contract_dependency { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Target { + #[prost(message, tag="2")] + Specification(super::SpecificationRef), + #[prost(message, tag="3")] + TypeLibrary(super::TypeLibraryRef), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Specification { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(string, tag="2")] + pub title: ::prost::alloc::string::String, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, + #[prost(message, optional, tag="5")] + pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="6")] + pub artifacts: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub schemas: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="8")] + pub messages: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="9")] + pub operations: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="10")] + pub exposures: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="11")] + pub usages: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="12")] + pub dependencies: ::prost::alloc::vec::Vec, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ContractMemberKind { + Unspecified = 0, + Artifact = 1, + Schema = 2, + Message = 3, + Operation = 4, +} +impl ContractMemberKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "CONTRACT_MEMBER_KIND_UNSPECIFIED", + Self::Artifact => "CONTRACT_MEMBER_KIND_ARTIFACT", + Self::Schema => "CONTRACT_MEMBER_KIND_SCHEMA", + Self::Message => "CONTRACT_MEMBER_KIND_MESSAGE", + Self::Operation => "CONTRACT_MEMBER_KIND_OPERATION", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "CONTRACT_MEMBER_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "CONTRACT_MEMBER_KIND_ARTIFACT" => Some(Self::Artifact), + "CONTRACT_MEMBER_KIND_SCHEMA" => Some(Self::Schema), + "CONTRACT_MEMBER_KIND_MESSAGE" => Some(Self::Message), + "CONTRACT_MEMBER_KIND_OPERATION" => Some(Self::Operation), + _ => None, + } + } +} // ============================================================================= // Event Modeling schema. // @@ -3528,7 +4074,7 @@ pub mod data_store { pub struct Entity { #[prost(message, optional, tag="29")] pub system: ::core::option::Option, - #[prost(oneof="entity::Kind", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32")] + #[prost(oneof="entity::Kind", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33")] pub kind: ::core::option::Option, } /// Nested message and enum types in `Entity`. @@ -3597,6 +4143,8 @@ pub mod entity { Channel(super::Channel), #[prost(message, tag="32")] DataStore(super::DataStore), + #[prost(message, tag="33")] + Specification(super::Specification), } } // @@protoc_insertion_point(module) diff --git a/rsworkspace/crates/trogon-atlas-proto/src/lib.rs b/rsworkspace/crates/trogon-atlas-proto/src/lib.rs index 1c4f1661..7aaeb351 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/lib.rs @@ -13,15 +13,15 @@ pub const SCHEMA_VERSION: &str = "trogonatlas.eventmodel.v1alpha1"; /// Wire contract revision this build speaks, advertised by the server as /// `GetServerInfoResponse.contract_revision` and checked by clients before /// mutating. See `docs/explanation/wire-compatibility.md` for when it changes. -pub const CONTRACT_REVISION: u32 = 10; +pub const CONTRACT_REVISION: u32 = 11; /// Oldest server contract revision clients built from this tree mutate /// against. -pub const MIN_SERVER_CONTRACT_REVISION: u32 = 10; +pub const MIN_SERVER_CONTRACT_REVISION: u32 = 11; /// Oldest client contract revision a server built from this tree accepts /// mutations from. -pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 10; +pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 11; /// Serialized `FileDescriptorSet` for the schema, for reflection-based /// tooling (textproto/JSON transcoding). @@ -224,6 +224,7 @@ pub mod canonical { EntityKind::TypeLibrary => "type_library", EntityKind::Channel => "channel", EntityKind::DataStore => "data_store", + EntityKind::Specification => "specification", } } @@ -263,6 +264,7 @@ pub mod canonical { EntityKind::TypeLibrary, EntityKind::Channel, EntityKind::DataStore, + EntityKind::Specification, ]; /// Parse a kind string into an `EntityKind`. Accepts the canonical diff --git a/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock b/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock index 3e0dce37..55e7e9dc 100644 --- a/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock +++ b/rsworkspace/crates/trogon-atlas-server/fuzz/Cargo.lock @@ -1091,7 +1091,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -2222,7 +2222,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -2392,9 +2392,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -3792,14 +3792,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs index 63a0f765..3243d7b5 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs @@ -1,5 +1,6 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{hash_map::Entry, HashMap, HashSet}; +use tonic::Code; use trogon_atlas_store::store::BranchLandOp; use super::{ @@ -81,7 +82,9 @@ impl EventModelServiceImpl { meta.is_put && matches!( meta.kind, - pb::EntityKind::Channel | pb::EntityKind::DataStore + pb::EntityKind::Channel + | pb::EntityKind::DataStore + | pb::EntityKind::Specification ) }) .filter_map(|meta| meta.entity.as_ref()) @@ -145,6 +148,16 @@ impl EventModelServiceImpl { &issues, ))); } + for selection in crate::specification_resources::library_types(&prepared) { + if let (Some(id), Some(message)) = ( + selection.library.as_ref().and_then(|v| v.id.as_ref()), + selection.message.as_ref(), + ) { + self.type_libraries + .check_contract_type(id, &message.value, &context) + .await?; + } + } } Ok(()) } @@ -161,7 +174,11 @@ impl EventModelServiceImpl { .filter(|entity| { matches!( entity.kind, - Some(pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_)) + Some( + pb::entity::Kind::Channel(_) + | pb::entity::Kind::DataStore(_) + | pb::entity::Kind::Specification(_) + ) ) }) .collect(); @@ -206,6 +223,52 @@ impl EventModelServiceImpl { Ok(world.into_values().collect()) } + pub(super) async fn specification_type_diagnostics( + &self, + snapshot: &[StoredEntity], + context: &[StoredEntity], + ) -> Result, Status> { + let mut issues = Vec::new(); + let mut checked = HashMap::new(); + for stored in snapshot { + for selection in crate::specification_resources::library_types(&stored.entity) { + let (Some(id), Some(message)) = ( + selection.library.as_ref().and_then(|v| v.id.as_ref()), + selection.message.as_ref(), + ) else { + continue; + }; + let key = ( + pb::EntityKey::new(pb::EntityKind::TypeLibrary, id), + message.value.clone(), + ); + let result = match checked.entry(key) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => entry.insert( + self.type_libraries + .check_contract_type(id, &message.value, context) + .await, + ), + }; + if let Err(status) = result { + if !matches!( + status.code(), + Code::InvalidArgument | Code::FailedPrecondition + ) { + return Err(status.clone()); + } + issues.push(validation::issue_error( + "SPECIFICATION_REFERENCE_UNRESOLVED", + "Selected protobuf message is absent, unreadable or does not compile in its exact library".into(), + crate::conv::entity_to_ref(&stored.entity).ok(), + "messages.schemas.library_type", + )); + } + } + } + Ok(issues) + } + pub(super) async fn architecture_reference_snapshot( &self, snapshot: &[StoredEntity], @@ -224,7 +287,11 @@ impl EventModelServiceImpl { .store .list( super::ListFilter { - kinds: &[pb::EntityKind::Channel, pb::EntityKind::DataStore], + kinds: &[ + pb::EntityKind::Channel, + pb::EntityKind::DataStore, + pb::EntityKind::Specification, + ], namespaces: &namespaces, ..Default::default() }, @@ -268,7 +335,8 @@ impl EventModelServiceImpl { loaded: &mut HashSet, world: &mut HashMap, ) -> Result<(), Status> { - for (kind, id) in targets { + let mut pending = targets; + while let Some((kind, id)) = pending.pop() { match access { ArchitectureTargetAccess::Required(vis) => { self.require_visible(vis, &id.namespace).await?; @@ -306,6 +374,16 @@ impl EventModelServiceImpl { None => stored, }; if let Some(stored) = next { + if let Some(pb::entity::Kind::TypeLibrary(library)) = &stored.entity.kind { + for dependency in &library.dependencies { + if let Some(target) = &dependency.id { + if target.namespace != id.namespace { + continue; + } + pending.push((pb::EntityKind::TypeLibrary, target.clone())); + } + } + } world.insert(key, stored); } } @@ -320,7 +398,11 @@ impl EventModelServiceImpl { ) -> Result<(), Status> { if !matches!( entity.kind, - Some(pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_)) + Some( + pb::entity::Kind::Channel(_) + | pb::entity::Kind::DataStore(_) + | pb::entity::Kind::Specification(_) + ) ) { return Ok(()); } @@ -414,3 +496,267 @@ fn observation_endpoints( } endpoints } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod specification_validation_tests { + use std::{sync::Arc, time::Duration}; + + use tonic::{Code, Request}; + use tonic_types::StatusExt; + use trogon_atlas_proto::event_model_service_server::EventModelService; + use trogon_atlas_store::WriteContext; + use trogon_atlas_types::CompileRunner; + + use super::*; + use crate::{metrics::ServerMetrics, type_libraries::TypeLibraryGate}; + + fn library_id(namespace: &str, version: u64) -> pb::Id { + pb::Id { + namespace: namespace.into(), + slug: "orders".into(), + version, + } + } + + async fn fixture() -> EventModelServiceImpl { + let nats = trogon_atlas_testsupport::shared().await; + EventModelServiceImpl::try_new(Arc::new(nats.store().await), ServerMetrics::default()) + .unwrap() + } + + async fn library( + service: &EventModelServiceImpl, + id: pb::Id, + source: &str, + dependencies: Vec, + ) { + service + .store + .put( + pb::EntityKind::TypeLibrary, + &pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(pb::TypeLibrary { + id: Some(id), + title: "Orders".into(), + files: vec![pb::ProtoSourceFile { + path: "orders.proto".into(), + content: source.into(), + }], + dependencies, + ..Default::default() + })), + ..Default::default() + }, + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + } + + const VALID: &str = "syntax = \"proto3\"; package orders.v1; message Order {}"; + + async fn selection(service: &EventModelServiceImpl, slug: &str, id: pb::Id, message: &str) { + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::Specification(pb::Specification { + id: Some(pb::Id { + namespace: "contracts".into(), + slug: slug.into(), + version: 1, + }), + title: slug.into(), + messages: vec![pb::ContractMessage { + name: Some(pb::MemberName { + value: "Payload".into(), + }), + schemas: vec![pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::LibraryType( + pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { id: Some(id) }), + message: Some(pb::QualifiedProtoName { + value: message.into(), + }), + }, + )), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + } + + async fn validate( + service: &EventModelServiceImpl, + ) -> Result, Status> { + service + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "contracts".into(), + )), + })) + .await + } + + fn issues(report: &pb::ValidateProjectResponse) -> Vec<&pb::ValidationIssue> { + report + .reports + .iter() + .flat_map(|r| &r.issues) + .filter(|i| { + i.code == "SPECIFICATION_REFERENCE_UNRESOLVED" + && i.subject_field == "messages.schemas.library_type" + }) + .collect() + } + + #[tokio::test] + async fn validate_project_propagates_compile_timeout() { + let mut service = fixture().await; + library(&service, library_id("types", 1), VALID, vec![]).await; + selection(&service, "typed", library_id("types", 1), "orders.v1.Order").await; + service.type_libraries = + TypeLibraryGate::with_compile_runner(CompileRunner::new(0, Duration::from_millis(1))); + let error = validate(&service) + .await + .expect_err("compiler timeout must fail the RPC, not diagnose the model"); + assert_eq!(error.code(), Code::DeadlineExceeded); + assert_eq!( + error.get_error_details().error_info().unwrap().reason, + crate::conv::reason::UNAVAILABLE + ); + for private in ["types", "orders", "Order", "orders.proto"] { + assert!(!format!("{error:?}").contains(private)); + } + } + + #[tokio::test] + async fn validate_project_memoizes_exact_selections_only_within_each_call() { + let service = fixture().await; + for (namespace, version) in [("types", 1), ("types", 2), ("other-types", 1)] { + library(&service, library_id(namespace, version), VALID, vec![]).await; + } + for (slug, namespace, version) in [ + ("first", "types", 1), + ("duplicate", "types", 1), + ("version", "types", 2), + ("namespace", "other-types", 1), + ] { + selection( + &service, + slug, + library_id(namespace, version), + "orders.v1.Order", + ) + .await; + } + let before = service.type_libraries.contract_compile_calls(); + let report = validate(&service).await.unwrap().into_inner(); + assert!(issues(&report).is_empty(), "{report:?}"); + assert_eq!(service.type_libraries.contract_compile_calls() - before, 3); + library( + &service, + library_id("types", 1), + "syntax = \"proto3\"; package orders.v1; message Replacement {}", + vec![], + ) + .await; + let report = validate(&service).await.unwrap().into_inner(); + let failures = issues(&report); + assert_eq!(failures.len(), 2, "{report:?}"); + for slug in ["first", "duplicate"] { + assert!( + failures.iter().any(|i| i + .subject + .as_ref() + .and_then(|r| r.id.as_ref()) + .is_some_and(|id| id.slug == slug)), + "{report:?}" + ); + } + assert_eq!(service.type_libraries.contract_compile_calls() - before, 6); + } + + #[tokio::test] + async fn validate_project_distinguishes_message_selections_and_redacts_model_errors() { + let service = fixture().await; + library( + &service, + library_id("types", 1), + "syntax = \"proto3\"; package orders.v1; message Order {} message Replacement {}", + vec![], + ) + .await; + selection( + &service, + "valid", + library_id("types", 1), + "orders.v1.Replacement", + ) + .await; + selection( + &service, + "removed", + library_id("types", 1), + "orders.v1.Order", + ) + .await; + library( + &service, + library_id("types", 1), + "syntax = \"proto3\"; package orders.v1; message Replacement {}", + vec![], + ) + .await; + let report = validate(&service).await.unwrap().into_inner(); + assert_eq!(issues(&report).len(), 1, "{report:?}"); + assert_eq!( + issues(&report)[0] + .subject + .as_ref() + .unwrap() + .id + .as_ref() + .unwrap() + .slug, + "removed" + ); + for (source, dependencies) in [ + ("syntax = \"proto3\"; private compiler source", vec![]), + ( + VALID, + vec![pb::TypeLibraryRef { + id: Some(pb::Id { + namespace: "types".into(), + slug: "secret-dependency".into(), + version: 1, + }), + }], + ), + ] { + library(&service, library_id("types", 1), source, dependencies).await; + let report = validate(&service).await.unwrap().into_inner(); + assert_eq!(issues(&report).len(), 2, "{report:?}"); + for issue in issues(&report) { + for private in [ + "orders.proto", + "secret-dependency", + "private compiler source", + "orders.v1", + ] { + assert!(!format!("{issue:?}").contains(private), "{issue:?}"); + } + } + } + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs index 1c8c43ff..d1725d35 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs @@ -94,12 +94,12 @@ impl<'a> EntityLookupKey<'a> { } } -pub(super) struct ArchitectureContext<'a> { +pub(crate) struct ArchitectureContext<'a> { entities: HashMap, &'a pb::Entity>, } impl<'a> ArchitectureContext<'a> { - pub(super) fn new(entities: impl IntoIterator) -> Self { + pub(crate) fn new(entities: impl IntoIterator) -> Self { Self { entities: entities .into_iter() @@ -114,7 +114,7 @@ impl<'a> ArchitectureContext<'a> { } } - fn find(&self, kind: pb::EntityKind, id: &pb::Id) -> Option<&'a pb::Entity> { + pub(crate) fn find(&self, kind: pb::EntityKind, id: &pb::Id) -> Option<&'a pb::Entity> { self.entities.get(&EntityLookupKey::new(kind, id)).copied() } } @@ -122,7 +122,11 @@ impl<'a> ArchitectureContext<'a> { pub(super) fn is_resource(entity: &pb::Entity) -> bool { matches!( entity.kind, - Some(pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_)) + Some( + pb::entity::Kind::Channel(_) + | pb::entity::Kind::DataStore(_) + | pb::entity::Kind::Specification(_) + ) ) } @@ -303,6 +307,9 @@ pub(super) fn validate_in_context( entity: &pb::Entity, context: &ArchitectureContext<'_>, ) -> Vec { + if matches!(entity.kind, Some(pb::entity::Kind::Specification(_))) { + return crate::specification_resources::validate_in_context(entity, context); + } let mut checks = Checks { subject: super::slice_ref(entity), issues: Vec::new(), diff --git a/rsworkspace/crates/trogon-atlas-server/src/conv.rs b/rsworkspace/crates/trogon-atlas-server/src/conv.rs index 3d4c444f..e8cf5068 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/conv.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/conv.rs @@ -311,6 +311,7 @@ pub fn entity_kind(entity: &pb::Entity) -> Result { Some(K::TypeLibrary(_)) => Ok(pb::EntityKind::TypeLibrary), Some(K::Channel(_)) => Ok(pb::EntityKind::Channel), Some(K::DataStore(_)) => Ok(pb::EntityKind::DataStore), + Some(K::Specification(_)) => Ok(pb::EntityKind::Specification), None => Err(Status::invalid_argument("entity.kind oneof is required")), } } @@ -349,6 +350,7 @@ pub fn entity_id(entity: &pb::Entity) -> Result<&pb::Id, Status> { Some(K::TypeLibrary(x)) => x.id.as_ref(), Some(K::Channel(x)) => x.id.as_ref(), Some(K::DataStore(x)) => x.id.as_ref(), + Some(K::Specification(x)) => x.id.as_ref(), None => return Err(Status::invalid_argument("entity.kind oneof is required")), }; let id = id.ok_or_else(|| Status::invalid_argument("entity.id is required"))?; @@ -430,6 +432,12 @@ pub fn validate_entity_content(entity: &pb::Entity) -> Result<(), Status> { Some(K::TypeLibrary(x)) => (x.title.as_str(), x.doc.as_str(), 0, x.metadata.len()), Some(K::Channel(x)) => (x.title.as_str(), x.doc.as_str(), 0, x.metadata.len()), Some(K::DataStore(x)) => (x.title.as_str(), x.doc.as_str(), 0, x.metadata.len()), + Some(K::Specification(x)) => ( + x.title.as_str(), + x.doc.as_deref().unwrap_or_default(), + 0, + x.metadata.len(), + ), None => return Ok(()), }; diff --git a/rsworkspace/crates/trogon-atlas-server/src/diff.rs b/rsworkspace/crates/trogon-atlas-server/src/diff.rs index 9354bb28..1ba26ac4 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/diff.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/diff.rs @@ -54,6 +54,7 @@ fn kind_name(e: &pb::Entity) -> &'static str { Some(K::TypeLibrary(_)) => "TypeLibrary", Some(K::Channel(_)) => "Channel", Some(K::DataStore(_)) => "DataStore", + Some(K::Specification(_)) => "Specification", None => "", } } @@ -598,6 +599,27 @@ fn diff_common(a: &pb::Entity, b: &pb::Entity, ops: &mut Vec) { ); resource_metadata(ops, &x.metadata, &y.metadata); } + (Some(K::Specification(x)), Some(K::Specification(y))) => { + resource_fields( + ops, + "Specification", + x, + y, + &[ + ("title", "title"), + ("doc", "doc"), + ("supersedes", "supersedes"), + ("artifacts", "artifacts"), + ("schemas", "schemas"), + ("messages", "messages"), + ("operations", "operations"), + ("exposures", "exposures"), + ("usages", "usages"), + ("dependencies", "dependencies"), + ], + ); + resource_metadata(ops, &x.metadata, &y.metadata); + } (Some(K::DataStore(x)), Some(K::DataStore(y))) => { scalar(ops, "title", &x.title, &y.title); scalar(ops, "doc", &x.doc, &y.doc); diff --git a/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs b/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs index c9caf95c..cffc933a 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs @@ -402,6 +402,7 @@ fn kind_dir(kind: pb::EntityKind) -> &'static str { pb::EntityKind::TypeLibrary => "type_libraries", pb::EntityKind::Channel => "channels", pb::EntityKind::DataStore => "data_stores", + pb::EntityKind::Specification => "specifications", pb::EntityKind::Unspecified => "unspecified", } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/graph.rs b/rsworkspace/crates/trogon-atlas-server/src/graph.rs index 0f6a88a1..fb025d2d 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/graph.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/graph.rs @@ -720,6 +720,7 @@ fn supersedes_of(entity: &pb::Entity) -> Option<&pb::Id> { K::TypeLibrary(x) => x.supersedes.as_ref(), K::Channel(x) => x.supersedes.as_ref(), K::DataStore(x) => x.supersedes.as_ref(), + K::Specification(x) => x.supersedes.as_ref(), } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/lib.rs b/rsworkspace/crates/trogon-atlas-server/src/lib.rs index a03ea201..cb02f99b 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod ownership; pub mod scope; pub mod search; pub mod service; +mod specification_resources; pub mod spicedb; pub mod telemetry; pub mod tls; diff --git a/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs b/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs index 891a7116..8b474db6 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs @@ -290,6 +290,7 @@ fn kind_label(k: pb::EntityKind) -> &'static str { pb::EntityKind::TypeLibrary => "type_library", pb::EntityKind::Channel => "channel", pb::EntityKind::DataStore => "data_store", + pb::EntityKind::Specification => "specification", pb::EntityKind::Unspecified => "unspecified", } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/search.rs b/rsworkspace/crates/trogon-atlas-server/src/search.rs index 004c7c99..cce8bcb6 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/search.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/search.rs @@ -422,6 +422,7 @@ fn kind_label(entity: &pb::Entity) -> String { Some(K::TypeLibrary(_)) => "type_library", Some(K::Channel(_)) => "channel", Some(K::DataStore(_)) => "data_store", + Some(K::Specification(_)) => "specification", None => "", } .to_string() @@ -461,6 +462,7 @@ pub fn kind_label_for(kind: pb::EntityKind) -> &'static str { pb::EntityKind::TypeLibrary => "type_library", pb::EntityKind::Channel => "channel", pb::EntityKind::DataStore => "data_store", + pb::EntityKind::Specification => "specification", pb::EntityKind::Unspecified => "", } } @@ -505,6 +507,7 @@ fn title_of(entity: &pb::Entity) -> String { Some(K::TypeLibrary(x)) => x.title.clone(), Some(K::Channel(x)) => x.title.clone(), Some(K::DataStore(x)) => x.title.clone(), + Some(K::Specification(x)) => x.title.clone(), None => String::new(), } } @@ -581,6 +584,29 @@ fn body_of(entity: &pb::Entity) -> String { push(&mut s, &x.target); } Some(K::Channel(x)) => push(&mut s, &x.doc), + Some(K::Specification(x)) => { + push(&mut s, x.doc.as_deref().unwrap_or_default()); + for (name, doc) in x + .artifacts + .iter() + .map(|v| (&v.name, &v.doc)) + .chain(x.schemas.iter().map(|v| (&v.name, &v.doc))) + .chain(x.messages.iter().map(|v| (&v.name, &v.doc))) + .chain(x.operations.iter().map(|v| (&v.name, &v.doc))) + { + if let Some(name) = name { + push(&mut s, &name.value); + } + if let Some(doc) = doc { + push(&mut s, doc); + } + } + for artifact in &x.artifacts { + if let Some(path) = &artifact.path { + push(&mut s, &path.value); + } + } + } Some(K::DataStore(x)) => { push(&mut s, &x.doc); if let Some(purpose) = &x.purpose { diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 9efe91bb..abcd25e5 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -6117,6 +6117,10 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // Project-level hygiene runs once for the whole call. let mut store_issues = validation::validate_store_with_architecture_context(&all, &architecture_context); + store_issues.extend( + self.specification_type_diagnostics(&all, &architecture_context) + .await?, + ); // Resolve which Domain(s) belong to the scope, then iterate // every stored EventModel whose namespace traces back to one of @@ -7315,7 +7319,10 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .ok_or_else(|| Status::invalid_argument("ref is required"))?; let kind = kind_from_i32(entity_ref.kind, "ref.kind")?; let id = require_id(entity_ref.id.as_ref(), "ref")?.clone(); - if matches!(kind, pb::EntityKind::Channel | pb::EntityKind::DataStore) { + if matches!( + kind, + pb::EntityKind::Channel | pb::EntityKind::DataStore | pb::EntityKind::Specification + ) { self.authorize_write( principal.as_ref(), Some(&req.name), diff --git a/rsworkspace/crates/trogon-atlas-server/src/specification_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/specification_resources.rs new file mode 100644 index 00000000..237e4dfd --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/src/specification_resources.rs @@ -0,0 +1,85 @@ +use trogon_atlas_proto as pb; + +use crate::validation::architecture_resources::ArchitectureContext; + +pub(crate) fn validate_in_context( + entity: &pb::Entity, + context: &ArchitectureContext<'_>, +) -> Vec { + let Some(pb::entity::Kind::Specification(spec)) = entity.kind.as_ref() else { + return Vec::new(); + }; + let subject = crate::conv::entity_to_ref(entity).ok(); + let mut issues = Vec::new(); + if let Err(error) = trogon_atlas_core::specification::validate_specification(spec) { + issues.push(crate::validation::issue_error( + "SPECIFICATION_VALUE_INVALID", + error.to_string(), + subject.clone(), + "specification", + )); + } + if let Some(prior) = &spec.supersedes { + if !spec.id.as_ref().is_some_and(|id| { + prior.namespace == id.namespace && prior.slug == id.slug && prior.version < id.version + }) { + issues.push(crate::validation::issue_error( + "SPECIFICATION_VALUE_INVALID", + "supersedes must select an earlier version of the same Specification".into(), + subject.clone(), + "supersedes", + )); + } + } + for reference in trogon_atlas_core::refs::outbound_refs(entity) { + let resolved = reference.to_kind.is_some_and(|kind| { + reference.to_id.version > 0 && context.find(kind, &reference.to_id).is_some() + }); + if !resolved { + issues.push(crate::validation::issue_error( + "SPECIFICATION_REFERENCE_UNRESOLVED", + "Specification target is absent or invalid".into(), + subject.clone(), + &reference.from_field, + )); + } + } + for (i, message) in spec.messages.iter().enumerate() { + for (j, binding) in message.schemas.iter().enumerate() { + if let Some(pb::schema_binding::Type::ContractType(reference)) = &binding.r#type { + let owner = reference + .schema + .as_ref() + .and_then(|value| value.specification.as_ref()) + .and_then(|value| value.id.as_ref()) + .and_then(|id| context.find(pb::EntityKind::Specification, id)); + if !owner.is_some_and(|owner| { + trogon_atlas_core::specification::resolve_type(owner, reference).is_ok() + }) { + issues.push(crate::validation::issue_error( + "SPECIFICATION_REFERENCE_UNRESOLVED", + "Contract schema member, incarnation, artifact, digest or selector pin does not resolve".into(), + subject.clone(), + &format!("messages[{i}].schemas[{j}].contract_type"), + )); + } + } + } + } + issues +} + +pub(crate) fn library_types(entity: &pb::Entity) -> Vec<&pb::TypeLibraryTypeRef> { + match &entity.kind { + Some(pb::entity::Kind::Specification(spec)) => spec + .messages + .iter() + .flat_map(|value| &value.schemas) + .filter_map(|value| match &value.r#type { + Some(pb::schema_binding::Type::LibraryType(value)) => Some(value), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/src/type_libraries.rs b/rsworkspace/crates/trogon-atlas-server/src/type_libraries.rs index 3e7e17a9..ea816008 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/type_libraries.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/type_libraries.rs @@ -96,6 +96,8 @@ pub struct TypeLibraryGate { runner: CompileRunner, limits: CompileLimits, pools: Arc>, + #[cfg(test)] + contract_compile_calls: Arc, } impl Default for TypeLibraryGate { @@ -106,6 +108,8 @@ impl Default for TypeLibraryGate { pools: Arc::new(TypePools::new(runner.clone(), limits)), runner, limits, + #[cfg(test)] + contract_compile_calls: Arc::default(), } } } @@ -130,6 +134,19 @@ struct Registered { } impl TypeLibraryGate { + #[cfg(test)] + pub(crate) fn with_compile_runner(runner: CompileRunner) -> Self { + Self { + runner, + ..Self::default() + } + } + + #[cfg(test)] + pub(crate) fn contract_compile_calls(&self) -> usize { + self.contract_compile_calls + .load(std::sync::atomic::Ordering::SeqCst) + } /// Checks a write of `entity`. Returns `None` for any other kind. /// /// # Errors @@ -358,6 +375,48 @@ impl TypeLibraryGate { }) } + pub(super) async fn check_contract_type( + &self, + library: &pb::Id, + message: &str, + context: &[trogon_atlas_store::StoredEntity], + ) -> Result<(), Status> { + let registered: BTreeMap<_, _> = context + .iter() + .filter_map(|stored| match &stored.entity.kind { + Some(pb::entity::Kind::TypeLibrary(value)) + if value + .id + .as_ref() + .is_some_and(|id| id.namespace == library.namespace) => + { + Some(( + LibraryVersion::of(value.id.as_ref()?), + Registered { + library: value.clone(), + etag: stored.etag.clone(), + }, + )) + } + _ => None, + }) + .collect(); + let key = LibraryVersion::of(library); + let selected = registered + .get(&key) + .ok_or_else(|| invalid("selected contract type library is unresolved".into()))?; + #[cfg(test)] + self.contract_compile_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let compiled = self.compile_registered(&key, selected, ®istered).await?; + if !compiled.message_names().iter().any(|name| name == message) { + return Err(invalid( + "selected contract protobuf message is unresolved".into(), + )); + } + Ok(()) + } + /// Finds the message `type_url` names among the namespace's live type /// libraries and the built-in types. /// @@ -797,11 +856,7 @@ fn reject_overlap( format!( "type library {own} owns packages under {prefix}, which overlap the packages under {other} owned by {key}" ), - ErrorDetails::with_error_info( - reason::ALREADY_EXISTS, - ERROR_DOMAIN, - HashMap::new(), - ), + ErrorDetails::with_error_info(reason::ALREADY_EXISTS, ERROR_DOMAIN, HashMap::new()), )); } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/validation.rs b/rsworkspace/crates/trogon-atlas-server/src/validation.rs index 493be13c..30edb0bb 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/validation.rs @@ -164,6 +164,8 @@ pub fn entity_kind_catalog() -> Vec { "Declared transport for exact AutomationSlice observations."), k(pb::EntityKind::DataStore, "ENTITY_KIND_DATA_STORE", "dataStore", "Data Store", "Data Stores", "Declared storage attached to exact ReadModels."), + k(pb::EntityKind::Specification, "ENTITY_KIND_SPECIFICATION", "specification", "Specification", "Specifications", + "Retained interface and schema declarations with exact message, operation, provider and usage selections."), k(TypeLibrary, "ENTITY_KIND_TYPE_LIBRARY", "typeLibrary", "Type Library", "Type Libraries", "Tenant-owned protobuf source files under a package prefix the library owns. The server compiles them on demand; the source is the only stored form."), ] @@ -216,6 +218,12 @@ pub fn rule_catalog() -> Vec { category: category.into(), }; vec![ + r("SPECIFICATION_VALUE_INVALID", Error, "Invalid Specification declaration", + "Contract values and local selections must satisfy the native Specification contract.", + pb::EntityKind::Specification, "", "architecture"), + r("SPECIFICATION_REFERENCE_UNRESOLVED", Error, "Specification selection does not resolve", + "Contract declarations select exact readable targets, owner incarnations and retained schema pins.", + pb::EntityKind::Specification, "", "architecture"), r("ARCHITECTURE_VALUE_INVALID", Error, "Invalid architecture declaration", "Transport and storage declarations must satisfy their native typed field contracts.", Unspecified, "", "architecture"), @@ -572,10 +580,12 @@ mod catalog_drift_tests { use super::*; - // Lifted from a `grep -oE` of this file. If you add an emission code + // Lifted from the validation emission sites across the validation modules. If you add an emission code // OR remove one, update both the catalog AND this list so the diff // test below can guard against drift. const EMITTED_CODES: &[&str] = &[ + "SPECIFICATION_VALUE_INVALID", + "SPECIFICATION_REFERENCE_UNRESOLVED", "ARCHITECTURE_VALUE_INVALID", "ARCHITECTURE_REFERENCE_UNRESOLVED", "CHANNEL_OBSERVATION_UNRESOLVED", @@ -725,6 +735,7 @@ mod catalog_drift_tests { pb::EntityKind::TypeLibrary, pb::EntityKind::Channel, pb::EntityKind::DataStore, + pb::EntityKind::Specification, ]; let got: HashSet = entity_kind_catalog().iter().map(|i| i.kind).collect(); let missing: Vec = want @@ -759,7 +770,7 @@ mod catalog_drift_tests { } } -fn issue_error( +pub(crate) fn issue_error( code: &str, message: String, subject: Option, @@ -1057,6 +1068,7 @@ fn entity_metadata(entity: &pb::Entity) -> &[prost_types::Any] { Some(pb::entity::Kind::TypeLibrary(x)) => &x.metadata, Some(pb::entity::Kind::Channel(x)) => &x.metadata, Some(pb::entity::Kind::DataStore(x)) => &x.metadata, + Some(pb::entity::Kind::Specification(x)) => &x.metadata, None => &[], } } diff --git a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs index acfadec6..48d1aac0 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs @@ -708,7 +708,8 @@ impl Store for FailingBatchStore { branch: Option<&str>, ) -> trogon_atlas_store::StoreResult> { let resource_scan = filter.kinds.contains(&pb::EntityKind::Channel) - || filter.kinds.contains(&pb::EntityKind::DataStore); + || filter.kinds.contains(&pb::EntityKind::DataStore) + || filter.kinds.contains(&pb::EntityKind::Specification); let rows = self.inner.list(filter, limit, branch).await?; if let Some(gate) = &self.resource_scan_gate { if resource_scan && gate.armed.swap(false, std::sync::atomic::Ordering::SeqCst) { diff --git a/rsworkspace/crates/trogon-atlas-server/tests/specification_fixture.rs b/rsworkspace/crates/trogon-atlas-server/tests/specification_fixture.rs new file mode 100644 index 00000000..0cdbd6d1 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/specification_fixture.rs @@ -0,0 +1,571 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::{collections::HashMap, sync::Arc}; + +use pb::event_model_service_server::EventModelService; +use tonic::Request; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "contracts".into(), + slug: slug.into(), + version: 1, + } +} + +fn name(value: &str) -> pb::MemberName { + pb::MemberName { + value: value.into(), + } +} + +fn reference(kind: pb::EntityKind, slug: &str) -> pb::EntityRef { + pb::EntityRef { + kind: kind as i32, + id: Some(id(slug)), + } +} + +async fn put(service: &EventModelServiceImpl, kind: pb::entity::Kind) -> pb::Entity { + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(kind), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .entity + .unwrap() +} + +async fn save(service: &EventModelServiceImpl, spec: pb::Specification) -> pb::Entity { + put(service, pb::entity::Kind::Specification(spec)).await +} + +fn specification(entity: &pb::Entity) -> pb::Specification { + match entity.kind.as_ref().unwrap() { + pb::entity::Kind::Specification(value) => value.clone(), + _ => panic!("expected Specification"), + } +} + +async fn contract(service: &EventModelServiceImpl, slug: &str) -> pb::Entity { + let content = "type Order { id: ID! }\n"; + let first = save( + service, + pb::Specification { + id: Some(id(slug)), + title: slug.into(), + artifacts: vec![pb::ContractArtifact { + name: Some(name("Order source")), + path: Some(pb::ArtifactPath { + value: "order.graphql".into(), + }), + format: Some(pb::ContractFormat { + kind: Some(pb::contract_format::Kind::Known( + pb::contract_format::Known::Graphql as i32, + )), + }), + media_type: Some(pb::MediaType { + value: "application/graphql".into(), + }), + content: content.into(), + digest: Some(pb::ContentDigest { + sha256: "ead4699b692541fca1d93a621700779902c58f2eec55ed73c3d32597a00ec136" + .into(), + }), + ..Default::default() + }], + ..Default::default() + }, + ) + .await; + let mut spec = specification(&first); + spec.schemas.push(pb::ContractSchema { + name: Some(name("Order shape")), + definition: Some(pb::SchemaDefinition { + target: Some(pb::schema_definition::Target::Artifact( + pb::LocalArtifactSelection { + artifact_id: spec.artifacts[0].member_id.clone(), + expected_digest: spec.artifacts[0].digest.clone(), + }, + )), + selector: Some(pb::OpaqueTypeSelector { + syntax: "graphql-type".into(), + value: "Order".into(), + }), + ..Default::default() + }), + ..Default::default() + }); + save(service, spec).await +} + +async fn outgoing( + service: &EventModelServiceImpl, + kind: pb::EntityKind, + slug: &str, +) -> Vec { + service + .get_outgoing_references(Request::new(pb::GetReferencesRequest { + kind: kind as i32, + id: Some(id(slug)), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .references +} + +#[tokio::test(flavor = "multi_thread")] +async fn connected_specification_fixture_preserves_roles_selections_and_native_export() { + let nats = trogon_atlas_testsupport::shared().await; + let service = + EventModelServiceImpl::try_new(Arc::new(nats.store().await), ServerMetrics::default()) + .unwrap(); + let mut expected = HashMap::new(); + for slug in ["order-request", "order-receipt"] { + let stored = put( + &service, + pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(id(slug)), + title: slug.into(), + ..Default::default() + }), + ) + .await; + expected.insert( + pb::EntityKey::new(pb::EntityKind::ReadModel, &id(slug)), + stored, + ); + } + for slug in ["public-gateway", "partner-gateway", "checkout"] { + let stored = put( + &service, + pb::entity::Kind::Component(pb::Component { + id: Some(id(slug)), + title: slug.into(), + ..Default::default() + }), + ) + .await; + expected.insert( + pb::EntityKey::new(pb::EntityKind::Component, &id(slug)), + stored, + ); + } + for (kind, slug, value) in [ + ( + pb::EntityKind::Processor, + "order-adapter", + pb::entity::Kind::Processor(pb::Processor { + id: Some(id("order-adapter")), + title: "Order adapter".into(), + ..Default::default() + }), + ), + ( + pb::EntityKind::Command, + "place-order", + pb::entity::Kind::Command(pb::Command { + id: Some(id("place-order")), + title: "Place order".into(), + ..Default::default() + }), + ), + ( + pb::EntityKind::Event, + "order-placed", + pb::entity::Kind::Event(pb::Event { + id: Some(id("order-placed")), + title: "Order placed".into(), + ..Default::default() + }), + ), + ( + pb::EntityKind::Schema, + "native-order-shape", + pb::entity::Kind::Schema(pb::Schema { + id: Some(id("native-order-shape")), + title: "Native order shape".into(), + ..Default::default() + }), + ), + ] { + expected.insert( + pb::EntityKey::new(kind, &id(slug)), + put(&service, value).await, + ); + } + let automation = put( + &service, + pb::entity::Kind::AutomationSlice(pb::AutomationSlice { + id: Some(id("order-intake")), + title: "Order intake".into(), + source_read_models: vec![pb::ReadModelEdge { + read_model: Some(pb::ReadModelRef { + id: Some(id("order-request")), + }), + ..Default::default() + }], + processor: Some(pb::ProcessorEdge { + processor: Some(pb::ProcessorRef { + id: Some(id("order-adapter")), + }), + ..Default::default() + }), + emitted_command: Some(pb::CommandEdge { + command: Some(pb::CommandRef { + id: Some(id("place-order")), + }), + ..Default::default() + }), + ..Default::default() + }), + ) + .await; + let observation = match automation.kind.as_ref().unwrap() { + pb::entity::Kind::AutomationSlice(value) => pb::ConnectionRef { + owner: Some(reference(pb::EntityKind::AutomationSlice, "order-intake")), + owner_uid: automation.system.as_ref().unwrap().uid.clone(), + id: value.source_read_models[0].connection_id.clone(), + }, + _ => panic!(), + }; + expected.insert( + pb::EntityKey::new(pb::EntityKind::AutomationSlice, &id("order-intake")), + automation, + ); + for (kind, slug, value) in [ + ( + pb::EntityKind::CommandSlice, + "handle-order", + pb::entity::Kind::CommandSlice(pb::CommandSlice { + id: Some(id("handle-order")), + title: "Handle order".into(), + command: Some(pb::CommandEdge { + command: Some(pb::CommandRef { + id: Some(id("place-order")), + }), + ..Default::default() + }), + emitted_events: vec![pb::EventEdge { + event: Some(pb::EventRef { + id: Some(id("order-placed")), + }), + ..Default::default() + }], + ..Default::default() + }), + ), + ( + pb::EntityKind::ReadModelSlice, + "receipt-projection", + pb::entity::Kind::ReadModelSlice(pb::ReadModelSlice { + id: Some(id("receipt-projection")), + title: "Receipt projection".into(), + source_events: vec![pb::EventEdge { + event: Some(pb::EventRef { + id: Some(id("order-placed")), + }), + ..Default::default() + }], + read_model: Some(pb::ReadModelEdge { + read_model: Some(pb::ReadModelRef { + id: Some(id("order-receipt")), + }), + ..Default::default() + }), + ..Default::default() + }), + ), + ( + pb::EntityKind::Channel, + "order-transport", + pb::entity::Kind::Channel(pb::Channel { + id: Some(id("order-transport")), + title: "Order transport".into(), + address: Some(pb::ChannelAddress { + template: "orders".into(), + ..Default::default() + }), + bindings: vec![pb::ChannelBinding { + observation: Some(observation), + ..Default::default() + }], + ..Default::default() + }), + ), + ] { + expected.insert( + pb::EntityKey::new(kind, &id(slug)), + put(&service, value).await, + ); + } + let library = put(&service, pb::entity::Kind::TypeLibrary(pb::TypeLibrary { + id: Some(id("fixture.contract")), title: "Order types".into(), + files: vec![pb::ProtoSourceFile { path: "orders.proto".into(), content: "syntax = \"proto3\"; package fixture.contract; message Order { string id = 1; }".into() }], ..Default::default() + })).await; + expected.insert( + pb::EntityKey::new(pb::EntityKind::TypeLibrary, &id("fixture.contract")), + library, + ); + let shared = contract(&service, "shared-contract").await; + let shared_spec = specification(&shared); + let external_type = pb::ContractTypeRef { + schema: Some(pb::ContractMemberRef { + specification: Some(pb::SpecificationRef { + id: Some(id("shared-contract")), + }), + owner_uid: shared.system.as_ref().unwrap().uid.clone(), + kind: pb::ContractMemberKind::Schema as i32, + member_id: shared_spec.schemas[0].member_id.clone(), + ..Default::default() + }), + expected_artifact_id: shared_spec.artifacts[0].member_id.clone(), + expected_digest: shared_spec.artifacts[0].digest.clone(), + expected_selector: shared_spec.schemas[0] + .definition + .as_ref() + .unwrap() + .selector + .clone(), + }; + expected.insert( + pb::EntityKey::new(pb::EntityKind::Specification, &id("shared-contract")), + shared, + ); + let initial = contract(&service, "order-api").await; + let mut spec = specification(&initial); + for (label, target) in [ + ("Submission", "order-request"), + ("Receipt", "order-receipt"), + ] { + spec.messages.push(pb::ContractMessage { + name: Some(name(label)), + schemas: vec![pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::LocalSchema( + spec.schemas[0].member_id.clone().unwrap(), + )), + ..Default::default() + }], + mapping: Some(pb::MessageReadModelMapping { + read_model: Some(pb::ReadModelRef { + id: Some(id(target)), + }), + ..Default::default() + }), + ..Default::default() + }); + } + for target in [ + pb::schema_binding::Type::ContractType(external_type), + pb::schema_binding::Type::Schema(pb::SchemaRef { + id: Some(id("native-order-shape")), + }), + pb::schema_binding::Type::LibraryType(pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { + id: Some(id("fixture.contract")), + }), + message: Some(pb::QualifiedProtoName { + value: "fixture.contract.Order".into(), + }), + }), + ] { + spec.messages[0].schemas.push(pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(target), + ..Default::default() + }); + } + let stored = save(&service, spec).await; + let mut spec = specification(&stored); + let request = spec.messages[0].member_id.clone(); + let response = spec.messages[1].member_id.clone(); + for (label, binding, occurrences) in [ + ( + "Submit", + pb::operation_binding::Kind::Http(pb::HttpOperationBinding { + method: "POST".into(), + path: "/orders".into(), + response_status_codes: vec![201], + }), + vec![ + (request, pb::operation_message::Role::Request), + (response.clone(), pb::operation_message::Role::Response), + ], + ), + ( + "Read receipt", + pb::operation_binding::Kind::Http(pb::HttpOperationBinding { + method: "GET".into(), + path: "/orders/{id}".into(), + response_status_codes: vec![200], + }), + vec![(response.clone(), pb::operation_message::Role::Response)], + ), + ( + "Receipt notice", + pb::operation_binding::Kind::Channel(pb::ChannelOperationBinding { + channel: Some(pb::ChannelRef { + id: Some(id("order-transport")), + }), + ..Default::default() + }), + vec![(response, pb::operation_message::Role::Notification)], + ), + ] { + spec.operations.push(pb::ContractOperation { + name: Some(name(label)), + binding: Some(pb::OperationBinding { + kind: Some(binding), + }), + messages: occurrences + .into_iter() + .map(|(message_id, role)| pb::OperationMessage { + message_id, + role: role as i32, + ..Default::default() + }) + .collect(), + ..Default::default() + }); + } + let stored = save(&service, spec).await; + let mut spec = specification(&stored); + for provider in ["public-gateway", "partner-gateway"] { + spec.exposures.push(pb::ProviderExposure { + provider: Some(pb::ProviderTarget { + target: Some(pb::provider_target::Target::Component(pb::ComponentRef { + id: Some(id(provider)), + })), + }), + operation_id: spec.operations[0].member_id.clone(), + ..Default::default() + }); + } + let stored = save(&service, spec).await; + let mut spec = specification(&stored); + for exposure in [spec.exposures[0].connection_id.clone(), None] { + spec.usages.push(pb::ApiUsage { + caller: Some(pb::ProviderTarget { + target: Some(pb::provider_target::Target::Component(pb::ComponentRef { + id: Some(id("checkout")), + })), + }), + operation_id: spec.operations[0].member_id.clone(), + exposure_id: exposure, + ..Default::default() + }); + } + let stored = save(&service, spec).await; + let spec = specification(&stored); + assert_eq!(spec.usages[0].exposure_id, spec.exposures[0].connection_id); + assert_ne!(spec.usages[0].exposure_id, spec.exposures[1].connection_id); + assert!(spec.usages[1].exposure_id.is_none()); + let submit = &spec.operations[0]; + assert_eq!( + submit.messages[0].role, + pb::operation_message::Role::Request as i32 + ); + assert_eq!( + submit.messages[1].role, + pb::operation_message::Role::Response as i32 + ); + assert_eq!(submit.messages[0].message_id, spec.messages[0].member_id); + assert_eq!(submit.messages[1].message_id, spec.messages[1].member_id); + assert_ne!( + submit.messages[1].connection_id, + spec.operations[1].messages[0].connection_id + ); + assert_ne!( + spec.messages[0].mapping.as_ref().unwrap().read_model, + spec.messages[1].mapping.as_ref().unwrap().read_model + ); + assert_eq!( + spec.messages[0].schemas[0].r#type, + spec.messages[1].schemas[0].r#type + ); + expected.insert( + pb::EntityKey::new(pb::EntityKind::Specification, &id("order-api")), + stored, + ); + let refs = outgoing(&service, pb::EntityKind::Specification, "order-api").await; + for (kind, slug) in [ + (pb::EntityKind::ReadModel, "order-request"), + (pb::EntityKind::ReadModel, "order-receipt"), + (pb::EntityKind::Component, "public-gateway"), + (pb::EntityKind::Component, "partner-gateway"), + (pb::EntityKind::Component, "checkout"), + (pb::EntityKind::Specification, "shared-contract"), + (pb::EntityKind::Schema, "native-order-shape"), + (pb::EntityKind::TypeLibrary, "fixture.contract"), + (pb::EntityKind::Channel, "order-transport"), + ] { + assert!( + refs.iter() + .any(|r| r.to.as_ref() == Some(&reference(kind, slug))), + "missing {kind:?}/{slug}" + ); + } + assert!(!refs.iter().any(|r| r.to.as_ref().is_some_and(|r| [ + pb::EntityKind::Command as i32, + pb::EntityKind::Event as i32, + pb::EntityKind::Processor as i32 + ] + .contains(&r.kind)))); + assert_eq!( + outgoing(&service, pb::EntityKind::Component, "checkout").await, + [] as [pb::Reference; 0] + ); + let model = put( + &service, + pb::entity::Kind::EventModel(pb::EventModel { + id: Some(id("order-contracts")), + title: "Order contracts".into(), + members: expected + .keys() + .map(|key| pb::EntityRef { + kind: key.kind as i32, + id: Some(key.id()), + }) + .collect(), + ..Default::default() + }), + ) + .await; + expected.insert( + pb::EntityKey::new(pb::EntityKind::EventModel, &id("order-contracts")), + model, + ); + let exported = service + .export_event_model_manifests(Request::new(pb::ExportEventModelManifestsRequest { + id: Some(id("order-contracts")), + })) + .await + .unwrap() + .into_inner(); + assert!(!exported.truncated); + assert_eq!(exported.skipped, [] as [pb::SkippedEntity; 0]); + let manifests = trogon_atlas_client::manifest::parse_str( + &exported.manifests, + "specification-fixture-export", + ) + .unwrap(); + assert_eq!(manifests.len(), expected.len()); + for manifest in manifests { + let key = pb::EntityKey::new(manifest.kind, &manifest.id); + assert_eq!( + manifest.entity.kind, expected[&key].kind, + "export changed {key}" + ); + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/specification_resources.rs b/rsworkspace/crates/trogon-atlas-server/tests/specification_resources.rs new file mode 100644 index 00000000..6ca534e7 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/specification_resources.rs @@ -0,0 +1,1368 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::Arc; + +use pb::event_model_service_server::EventModelService; +use tonic::{Code, Request}; +use trogon_atlas_core::specification::{content_digest, is_api}; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "contracts".into(), + slug: slug.into(), + version: 1, + } +} +fn name(value: &str) -> pb::MemberName { + pb::MemberName { + value: value.into(), + } +} +fn entity(spec: pb::Specification) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Specification(spec)), + ..Default::default() + } +} +fn spec(entity: &pb::Entity) -> pb::Specification { + match &entity.kind { + Some(pb::entity::Kind::Specification(value)) => value.clone(), + _ => panic!("Specification"), + } +} +fn artifact(content: &str) -> pb::ContractArtifact { + pb::ContractArtifact { + name: Some(name("Source")), + path: Some(pb::ArtifactPath { + value: "contract.txt".into(), + }), + format: Some(pb::ContractFormat { + kind: Some(pb::contract_format::Kind::Other("opaque".into())), + }), + media_type: Some(pb::MediaType { + value: "text/plain".into(), + }), + content: content.into(), + digest: Some(content_digest(content)), + ..Default::default() + } +} +fn declaration(slug: &str) -> pb::Specification { + pb::Specification { + id: Some(id(slug)), + title: slug.into(), + artifacts: vec![artifact("schema\r\n")], + messages: vec![pb::ContractMessage { + name: Some(name("Message")), + ..Default::default() + }], + ..Default::default() + } +} +async fn service() -> EventModelServiceImpl { + let nats = trogon_atlas_testsupport::shared().await; + EventModelServiceImpl::try_new(Arc::new(nats.store().await), ServerMetrics::default()).unwrap() +} +async fn save(service: &EventModelServiceImpl, spec: pb::Specification) -> pb::Entity { + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(entity(spec)), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .entity + .unwrap() +} +async fn get(service: &EventModelServiceImpl, slug: &str) -> pb::Entity { + service + .get_entity(Request::new(pb::GetEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(id(slug)), + })) + .await + .unwrap() + .into_inner() + .entity + .unwrap() +} +async fn invalid_every_surface(service: &EventModelServiceImpl, value: pb::Specification) { + for preview in [true, false] { + let error = service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(entity(value.clone())), + validate_only: preview, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error:?}"); + let error = service + .batch_mutate(Request::new(pb::BatchMutateRequest { + ops: vec![pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(entity(value.clone())), + ..Default::default() + })), + }], + validate_only: preview, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error:?}"); + } +} +async fn contract(service: &EventModelServiceImpl, slug: &str) -> pb::Entity { + let first = save(service, declaration(slug)).await; + let mut value = spec(&first); + value.schemas.push(pb::ContractSchema { + name: Some(name("Shape")), + definition: Some(pb::SchemaDefinition { + target: Some(pb::schema_definition::Target::Artifact( + pb::LocalArtifactSelection { + artifact_id: value.artifacts[0].member_id.clone(), + expected_digest: value.artifacts[0].digest.clone(), + }, + )), + selector: Some(pb::OpaqueTypeSelector { + syntax: "unrecognized".into(), + value: "arbitrary opaque text".into(), + }), + ..Default::default() + }), + ..Default::default() + }); + save(service, value).await +} +fn pin(owner: &pb::Entity) -> pb::ContractTypeRef { + let value = spec(owner); + pb::ContractTypeRef { + schema: Some(pb::ContractMemberRef { + specification: Some(pb::SpecificationRef { + id: value.id.clone(), + }), + owner_uid: owner.system.as_ref().unwrap().uid.clone(), + kind: pb::ContractMemberKind::Schema as i32, + member_id: value.schemas[0].member_id.clone(), + expected_digest: None, + }), + expected_artifact_id: value.artifacts[0].member_id.clone(), + expected_digest: value.artifacts[0].digest.clone(), + expected_selector: value.schemas[0] + .definition + .as_ref() + .unwrap() + .selector + .clone(), + } +} +fn consumer(reference: pb::ContractTypeRef) -> pb::Specification { + let mut value = declaration("consumer"); + value.messages[0].schemas.push(pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::ContractType(reference)), + ..Default::default() + }); + value +} +fn branch(value: T) -> Request { + let mut request = Request::new(value); + request + .metadata_mut() + .insert("x-trogon-atlas-branch", "contract-work".parse().unwrap()); + request +} + +#[tokio::test] +async fn exact_type_pin_checks_owner_kind_artifact_digest_and_selector_presence() { + let service = service().await.with_snapshot_cap(0); + let owner = contract(&service, "shared").await; + let good = pin(&owner); + save(&service, consumer(good.clone())).await; + let mut cases = Vec::new(); + let mut value = good.clone(); + value.schema.as_mut().unwrap().owner_uid = "stale".into(); + cases.push(value); + let mut value = good.clone(); + value.schema.as_mut().unwrap().kind = pb::ContractMemberKind::Artifact as i32; + cases.push(value); + let mut value = good.clone(); + value.expected_artifact_id = Some(trogon_atlas_core::specification::MemberId::new().into()); + cases.push(value); + let mut value = good.clone(); + value.expected_digest.as_mut().unwrap().sha256 = "0".repeat(64); + cases.push(value); + let mut value = good.clone(); + value.expected_selector = None; + cases.push(value); + let mut value = good.clone(); + value.expected_selector.as_mut().unwrap().value = "changed".into(); + cases.push(value); + let mut value = good; + value.schema.as_mut().unwrap().expected_digest = value.expected_digest.clone(); + cases.push(value); + let before = get(&service, "consumer").await; + for reference in cases { + let mut bad = spec(&before); + bad.messages[0].schemas[0].r#type = Some(pb::schema_binding::Type::ContractType(reference)); + bad.messages[0].schemas[0].connection_id = None; + invalid_every_surface(&service, bad).await; + assert_eq!(get(&service, "consumer").await, before); + } +} + +#[tokio::test] +async fn malformed_values_limits_and_roles_are_atomic_on_public_mutations() { + let service = service().await; + let mut cases = Vec::new(); + let mut value = declaration("bad"); + value.artifacts[0].digest.as_mut().unwrap().sha256 = "0".repeat(64); + cases.push(value); + let mut value = declaration("bad"); + value.artifacts[0].path.as_mut().unwrap().value = "../secret".into(); + cases.push(value); + let mut value = declaration("bad"); + value.artifacts[0].format.as_mut().unwrap().kind = Some(pb::contract_format::Kind::Known(999)); + cases.push(value); + let mut value = declaration("bad"); + value.artifacts[0] = artifact(&"a".repeat(256 * 1024 + 1)); + cases.push(value); + let mut value = declaration("bad"); + value.messages = (0..1025) + .map(|i| pb::ContractMessage { + name: Some(name(&format!("message-{i}"))), + ..Default::default() + }) + .collect(); + cases.push(value); + let mut value = declaration("bad"); + value.messages[0].schemas.push(pb::SchemaBinding { + role: 999, + ..Default::default() + }); + cases.push(value); + let mut value = declaration("bad"); + value.operations.push(pb::ContractOperation { + name: Some(name("Operation")), + messages: vec![pb::OperationMessage { + role: 999, + ..Default::default() + }], + ..Default::default() + }); + cases.push(value); + for value in cases { + invalid_every_surface(&service, value).await; + } + assert_eq!( + service + .get_entity(Request::new(pb::GetEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(id("bad")) + })) + .await + .unwrap_err() + .code(), + Code::NotFound + ); +} + +#[tokio::test] +async fn artifact_bytes_are_immutable_but_labels_and_member_order_preserve_identity() { + let service = service().await; + let original = contract(&service, "immutable").await; + let mut changed = spec(&original); + changed.artifacts[0].content = "new bytes\n".into(); + changed.artifacts[0].digest = Some(content_digest("new bytes\n")); + invalid_every_surface(&service, changed).await; + let mut renamed = spec(&original); + renamed.doc = Some("Revised documentation".into()); + renamed.artifacts[0].name = Some(name("Renamed")); + renamed.artifacts[0].path.as_mut().unwrap().value = "renamed.txt".into(); + let persisted = save(&service, renamed).await; + assert_eq!( + spec(&persisted).artifacts[0].member_id, + spec(&original).artifacts[0].member_id + ); + assert_eq!( + spec(&persisted).artifacts[0].content.as_bytes(), + b"schema\r\n" + ); + assert_eq!( + spec(&persisted).schemas[0] + .definition + .as_ref() + .unwrap() + .connection_id, + spec(&original).schemas[0] + .definition + .as_ref() + .unwrap() + .connection_id + ); +} + +#[tokio::test] +async fn permitted_schema_target_edit_invalidates_retained_foreign_pin_without_blocking_landing() { + let service = service().await; + let owner = contract(&service, "shared").await; + let stored_consumer = save(&service, consumer(pin(&owner))).await; + let mut updated = spec(&owner); + updated.artifacts.push(pb::ContractArtifact { + name: Some(name("Replacement")), + path: Some(pb::ArtifactPath { + value: "replacement.txt".into(), + }), + ..artifact("replacement") + }); + let added = save(&service, updated).await; + let mut retargeted = spec(&added); + let new = retargeted.artifacts[1].clone(); + let definition = retargeted.schemas[0].definition.as_mut().unwrap(); + definition.connection_id = None; + definition.target = Some(pb::schema_definition::Target::Artifact( + pb::LocalArtifactSelection { + artifact_id: new.member_id, + expected_digest: new.digest, + }, + )); + save(&service, retargeted).await; + assert_eq!(get(&service, "consumer").await, stored_consumer); + let mut edited = spec(&stored_consumer); + edited.doc = Some("New edit must validate stale selection".into()); + invalid_every_surface(&service, edited).await; + let report = service + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "contracts".into(), + )), + })) + .await + .unwrap() + .into_inner(); + assert!(report + .reports + .iter() + .flat_map(|v| &v.issues) + .any(|v| v.code == "SPECIFICATION_REFERENCE_UNRESOLVED")); + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "contract-work".into(), + ..Default::default() + })) + .await + .unwrap(); + service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::Event(pb::Event { + id: Some(id("unrelated")), + title: "Unrelated".into(), + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + for dry_run in [true, false] { + service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "contract-work".into(), + dry_run, + ..Default::default() + })) + .await + .unwrap(); + } + assert_eq!(get(&service, "consumer").await, stored_consumer); +} + +#[tokio::test] +async fn branch_tombstone_cannot_resolve_from_baseline_and_forced_delete_keeps_declaration() { + let service = service().await.with_snapshot_cap(0); + let owner = contract(&service, "shared").await; + let reference = pin(&owner); + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "contract-work".into(), + ..Default::default() + })) + .await + .unwrap(); + service + .delete_entity(branch(pb::DeleteEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(id("shared")), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })) + .await + .unwrap(); + for validate_only in [true, false] { + let error = service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(entity(consumer(reference.clone()))), + validate_only, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + } + let stored = save(&service, consumer(reference)).await; + service + .delete_entity(Request::new(pb::DeleteEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: Some(id("shared")), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })) + .await + .unwrap(); + assert_eq!(get(&service, "consumer").await, stored); +} + +#[tokio::test] +async fn operation_bearing_standalone_contract_requires_no_invented_provider() { + let service = service().await; + let original = save(&service, declaration("standalone")).await; + let mut value = spec(&original); + value.operations.push(pb::ContractOperation { + name: Some(name("Notify")), + messages: vec![pb::OperationMessage { + message_id: value.messages[0].member_id.clone(), + role: pb::operation_message::Role::Notification as i32, + ..Default::default() + }], + ..Default::default() + }); + let persisted = save(&service, value).await; + assert!(!is_api(&spec(&persisted))); + assert_eq!( + spec(&persisted).operations[0].messages[0].role, + pb::operation_message::Role::Notification as i32 + ); + assert_eq!(spec(&persisted).exposures, []); +} + +fn library(slug: &str, message: &str) -> pb::TypeLibrary { + pb::TypeLibrary { + id: Some(id(slug)), + title: slug.into(), + files: vec![pb::ProtoSourceFile { + path: format!("{slug}.proto"), + content: format!( + "syntax = \"proto3\"; package {slug}.v1; message {message} {{ string id = 1; }}" + ), + }], + ..Default::default() + } +} +async fn save_library(service: &EventModelServiceImpl, value: pb::TypeLibrary) { + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(value)), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); +} + +#[tokio::test] +async fn exact_selected_library_message_compiles_with_own_namespace_imports_beyond_projection() { + let service = service().await.with_snapshot_cap(0); + let mut dependency = library("shared", "Dependency"); + dependency.id.as_mut().unwrap().namespace = "types".into(); + save_library(&service, dependency.clone()).await; + let mut selected = library("orders", "Order"); + selected.id.as_mut().unwrap().namespace = "types".into(); + selected.dependencies.push(pb::TypeLibraryRef { + id: dependency.id.clone(), + }); + selected.files[0].content = "syntax = \"proto3\"; package orders.v1; import \"shared.proto\"; message Order { shared.v1.Dependency value = 1; }".into(); + save_library(&service, selected.clone()).await; + let mut value = declaration("typed"); + value.messages[0].schemas.push(pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::LibraryType( + pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { + id: selected.id.clone(), + }), + message: Some(pb::QualifiedProtoName { + value: "orders.v1.Order".into(), + }), + }, + )), + ..Default::default() + }); + let persisted = save(&service, value).await; + for name in [ + "shared.v1.Dependency", + "orders.v1.Missing", + ".orders.v1.Order", + ] { + let mut invalid = spec(&persisted); + if let Some(pb::schema_binding::Type::LibraryType(reference)) = + invalid.messages[0].schemas[0].r#type.as_mut() + { + reference.message.as_mut().unwrap().value = name.into(); + } + invalid.messages[0].schemas[0].connection_id = None; + invalid_every_surface(&service, invalid).await; + } +} + +#[tokio::test] +async fn typed_library_imports_use_batch_overlays_and_branch_tombstones_beyond_projection() { + let service = service().await.with_snapshot_cap(0); + let mut selected = library("orders", "Order"); + selected.id.as_mut().unwrap().namespace = "types".into(); + save_library(&service, selected.clone()).await; + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "contract-work".into(), + ..Default::default() + })) + .await + .unwrap(); + let mut imported = library("extra", "Extra"); + imported.id.as_mut().unwrap().namespace = "types".into(); + service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(imported.clone())), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + selected.dependencies.push(pb::TypeLibraryRef { + id: imported.id.clone(), + }); + selected.files[0].content = "syntax = \"proto3\"; package orders.v1; import \"extra.proto\"; message Order { string id = 1; } message BranchOnly { extra.v1.Extra value = 1; }".into(); + let mut value = typed_declaration("branch-typed", &selected); + if let Some(pb::schema_binding::Type::LibraryType(reference)) = + value.messages[0].schemas[0].r#type.as_mut() + { + reference.message.as_mut().unwrap().value = "orders.v1.BranchOnly".into(); + } + for validate_only in [true, false] { + let response = service + .batch_mutate(branch(pb::BatchMutateRequest { + ops: vec![ + entity(value.clone()), + pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(selected.clone())), + ..Default::default() + }, + ] + .into_iter() + .map(|entity| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(entity), + ..Default::default() + })), + }) + .collect(), + validate_only, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert!(response.failure.is_empty(), "{response:?}"); + assert_eq!( + response.status, + if validate_only { + pb::batch_mutate_response::Status::Validated as i32 + } else { + pb::batch_mutate_response::Status::Applied as i32 + }, + "{response:?}" + ); + } + invalid_every_surface(&service, value.clone()).await; + service + .delete_entity(branch(pb::DeleteEntityRequest { + kind: pb::EntityKind::TypeLibrary as i32, + id: selected.id.clone(), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })) + .await + .unwrap(); + for validate_only in [true, false] { + let mut missing = typed_declaration("missing", &selected); + missing.doc = Some("Baseline still has Order, but this branch deleted its library".into()); + let error = service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(entity(missing)), + validate_only, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error:?}"); + } +} + +#[tokio::test] +async fn retained_cross_namespace_imports_are_diagnosed_without_blocking_unrelated_landing() { + use trogon_atlas_store::{Store, WriteContext}; + + for transitive in [false, true] { + let nats = trogon_atlas_testsupport::shared().await; + let store = Arc::new(nats.store().await); + let service = + EventModelServiceImpl::try_new(store.clone(), ServerMetrics::default()).unwrap(); + let mut selected = library("orders", "Order"); + selected.id.as_mut().unwrap().namespace = "types".into(); + let mut imported = library("imported", "Imported"); + imported.id.as_mut().unwrap().namespace = "types".into(); + save_library(&service, imported.clone()).await; + if transitive { + selected.dependencies.push(pb::TypeLibraryRef { + id: imported.id.clone(), + }); + } + save_library(&service, selected.clone()).await; + let value = typed_declaration("typed", &selected); + let persisted = save(&service, value.clone()).await; + let mut malformed = if transitive { + imported + } else { + selected.clone() + }; + malformed.dependencies.push(pb::TypeLibraryRef { + id: Some(pb::Id { + namespace: "private-types".into(), + slug: "privateimport".into(), + version: 1, + }), + }); + store + .put( + pb::EntityKind::TypeLibrary, + &pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(malformed)), + ..Default::default() + }, + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + let report = service + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "contracts".into(), + )), + })) + .await + .expect("retained bad imports must not abort ValidateProject") + .into_inner(); + let failures: Vec<_> = report + .reports + .iter() + .flat_map(|r| &r.issues) + .filter(|i| i.subject_field == "messages.schemas.library_type") + .collect(); + assert_eq!(failures.len(), 1, "{report:?}"); + assert_eq!(failures[0].code, "SPECIFICATION_REFERENCE_UNRESOLVED"); + assert_eq!( + failures[0] + .subject + .as_ref() + .unwrap() + .id + .as_ref() + .unwrap() + .slug, + "typed" + ); + for private in ["private-types", "privateimport", "orders.proto"] { + assert!(!format!("{:?}", failures[0]).contains(private)); + } + let mut new_selection = value; + new_selection.id = Some(id("new-typed")); + let mut edited = spec(&persisted); + edited.doc = Some("Recheck typed selection".into()); + for rejected in [new_selection, edited] { + for validate_only in [true, false] { + let put = pb::PutEntityRequest { + entity: Some(entity(rejected.clone())), + validate_only, + ..Default::default() + }; + let errors = [ + service + .put_entity(Request::new(put.clone())) + .await + .unwrap_err(), + service + .batch_mutate(Request::new(pb::BatchMutateRequest { + ops: vec![pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(put)), + }], + validate_only, + ..Default::default() + })) + .await + .unwrap_err(), + ]; + for error in errors { + assert_eq!( + error.code(), + if transitive { + Code::FailedPrecondition + } else { + Code::InvalidArgument + }, + "{error:?}" + ); + } + } + } + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "contract-work".into(), + ..Default::default() + })) + .await + .unwrap(); + service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(entity(declaration("unrelated"))), + ..Default::default() + })) + .await + .unwrap(); + for dry_run in [true, false] { + service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "contract-work".into(), + dry_run, + ..Default::default() + })) + .await + .unwrap(); + } + assert_eq!(get(&service, "typed").await, persisted); + } +} + +fn typed_declaration(slug: &str, library: &pb::TypeLibrary) -> pb::Specification { + let mut value = declaration(slug); + value.messages[0].schemas.push(pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::LibraryType( + pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { + id: library.id.clone(), + }), + message: Some(pb::QualifiedProtoName { + value: "orders.v1.Order".into(), + }), + }, + )), + ..Default::default() + }); + value +} + +#[tokio::test] +async fn descriptive_library_dependency_does_not_compile_or_authorize_cross_namespace_imports() { + use trogon_atlas_store::{Store, WriteContext}; + + let nats = trogon_atlas_testsupport::shared().await; + let store = Arc::new(nats.store().await); + let service = EventModelServiceImpl::try_new(store.clone(), ServerMetrics::default()).unwrap(); + let own = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "contracts".into(), + parent: "owned".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let hidden = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "private-types".into(), + parent: "other".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let mut selected = library("orders", "Order"); + selected.id.as_mut().unwrap().namespace = own.clone(); + save_library(&service, selected.clone()).await; + let mut retained = typed_declaration("retained", &selected); + retained.id.as_mut().unwrap().namespace = own.clone(); + save(&service, retained).await; + let mut private_library = library("privateimport", "Private"); + private_library.id.as_mut().unwrap().namespace = hidden.clone(); + save_library(&service, private_library.clone()).await; + selected.dependencies.push(pb::TypeLibraryRef { + id: Some(pb::Id { + namespace: hidden.clone(), + slug: "privateimport".into(), + version: 1, + }), + }); + store + .put( + pb::EntityKind::TypeLibrary, + &pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(selected.clone())), + ..Default::default() + }, + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + let mut value = declaration("descriptive"); + value.id.as_mut().unwrap().namespace = own.clone(); + value.dependencies.push(pb::ContractDependency { + target: Some(pb::contract_dependency::Target::TypeLibrary( + pb::TypeLibraryRef { + id: selected.id.clone(), + }, + )), + ..Default::default() + }); + let mut typed = typed_declaration("typed", &selected); + typed.id.as_mut().unwrap().namespace = own.clone(); + let mut client = authenticated_client(service).await; + for validate_only in [true, false] { + client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(entity(value.clone())), + validate_only, + ..Default::default() + }, + false, + )) + .await + .expect( + "descriptive dependencies require readable exact existence, not valid compilation", + ); + let error = client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(entity(typed.clone())), + validate_only, + ..Default::default() + }, + false, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error:?}"); + } + let mut forbidden_dependency = value; + forbidden_dependency.id.as_mut().unwrap().slug = "hidden-dependency".into(); + forbidden_dependency.dependencies[0].target = Some( + pb::contract_dependency::Target::TypeLibrary(pb::TypeLibraryRef { + id: private_library.id.clone(), + }), + ); + let mut forbidden_type = typed_declaration("hidden-type", &private_library); + forbidden_type.id.as_mut().unwrap().namespace = own.clone(); + for forbidden in [forbidden_dependency, forbidden_type] { + let error = client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(entity(forbidden)), + ..Default::default() + }, + false, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::NotFound, "{error:?}"); + assert!(!error.message().contains("privateimport"), "{error:?}"); + } + let report = client + .validate_project(authed( + pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace(own)), + }, + true, + )) + .await + .unwrap() + .into_inner(); + let details = format!("{report:?}"); + assert!(!details.contains(&hidden), "{details}"); + assert!(!details.contains("privateimport"), "{details}"); + let failures: Vec<_> = report + .reports + .iter() + .flat_map(|r| &r.issues) + .filter(|i| i.subject_field == "messages.schemas.library_type") + .collect(); + assert_eq!(failures.len(), 1, "{report:?}"); + assert_eq!(failures[0].code, "SPECIFICATION_REFERENCE_UNRESOLVED"); + assert_eq!( + failures[0] + .subject + .as_ref() + .unwrap() + .id + .as_ref() + .unwrap() + .slug, + "retained" + ); +} + +#[tokio::test] +async fn breaking_library_edit_is_guarded_and_forced_removal_is_diagnosed_without_blocking_landing() +{ + let service = service().await; + save_library(&service, library("orders", "Order")).await; + let mut value = declaration("typed"); + value.messages[0].schemas.push(pb::SchemaBinding { + role: pb::schema_binding::Role::Payload as i32, + r#type: Some(pb::schema_binding::Type::LibraryType( + pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { + id: Some(id("orders")), + }), + message: Some(pb::QualifiedProtoName { + value: "orders.v1.Order".into(), + }), + }, + )), + ..Default::default() + }); + let persisted = save(&service, value).await; + let error = service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(library( + "orders", + "Replacement", + ))), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error + .message() + .contains("message orders.v1.Order was removed")); + service + .delete_entity(Request::new(pb::DeleteEntityRequest { + kind: pb::EntityKind::TypeLibrary as i32, + id: Some(id("orders")), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })) + .await + .unwrap(); + let report = service + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "contracts".into(), + )), + })) + .await + .unwrap() + .into_inner(); + assert!( + report + .reports + .iter() + .flat_map(|v| &v.issues) + .any(|v| v.code == "SPECIFICATION_REFERENCE_UNRESOLVED" + && v.subject_field == "messages.schemas.library_type"), + "{report:?}" + ); + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "contract-work".into(), + ..Default::default() + })) + .await + .unwrap(); + service + .put_entity(branch(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::Event(pb::Event { + id: Some(id("unrelated")), + title: "Unrelated".into(), + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + for dry_run in [true, false] { + service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "contract-work".into(), + dry_run, + ..Default::default() + })) + .await + .unwrap(); + } + assert_eq!(get(&service, "typed").await, persisted); + let mut edited = spec(&persisted); + edited.doc = Some("Must validate selected type".into()); + invalid_every_surface(&service, edited).await; +} + +async fn authenticated_client( + service: EventModelServiceImpl, +) -> pb::event_model_service_client::EventModelServiceClient { + use trogon_atlas_server::auth::{AuthzLayer, BearerAuth, TokenRegistry}; + let registry = toml::from_str::( + r#" +[principals.contract-writer] +role = "writer" +tokens = ["contract-writer-token"] +parent = "owned" +[principals.contract-reader] +role = "reader" +tokens = ["contract-reader-token"] +parent = "owned" +"#, + ) + .unwrap(); + let data_plane = tonic::service::interceptor::InterceptedService::new( + tower::ServiceBuilder::new() + .layer(AuthzLayer) + .service(pb::event_model_service_server::EventModelServiceServer::new(service)), + BearerAuth::new(Arc::new( + TokenRegistry::from_file_contents(registry).unwrap(), + )), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(data_plane) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + pb::event_model_service_client::EventModelServiceClient::connect(format!("http://{address}")) + .await + .unwrap() +} +fn authed(body: T, reader: bool) -> Request { + let mut request = Request::new(body); + request.metadata_mut().insert( + "authorization", + if reader { + "Bearer contract-reader-token" + } else { + "Bearer contract-writer-token" + } + .parse() + .unwrap(), + ); + request +} + +#[tokio::test] +async fn hidden_nested_referrer_protects_delete_without_exposing_its_details() { + let service = service().await.with_snapshot_cap(0); + let own = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "owned-contracts".into(), + parent: "owned".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let hidden = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "private-contracts".into(), + parent: "other".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let target = pb::Id { + namespace: own.clone(), + slug: "feed".into(), + version: 1, + }; + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(target.clone()), + title: "Feed".into(), + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + let mut value = declaration("hidden-contract"); + value.id.as_mut().unwrap().namespace = hidden.clone(); + value.messages[0].name = Some(name("Hidden message details")); + value.messages[0].mapping = Some(pb::MessageReadModelMapping { + read_model: Some(pb::ReadModelRef { + id: Some(target.clone()), + }), + ..Default::default() + }); + save(&service, value).await; + let mut client = authenticated_client(service).await; + let error = client + .delete_entity(authed( + pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: Some(target.clone()), + mode: pb::delete_entity_request::Mode::FailIfReferenced as i32, + ..Default::default() + }, + false, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition, "{error:?}"); + for secret in [&hidden, "hidden-contract", "Hidden message details"] { + assert!(!error.message().contains(secret)); + } + for dry_run in [true, false] { + let result = client + .batch_mutate(authed( + pb::BatchMutateRequest { + ops: vec![pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: Some(target.clone()), + mode: pb::delete_entity_request::Mode::FailIfReferenced as i32, + ..Default::default() + })), + }], + validate_only: dry_run, + ..Default::default() + }, + false, + )) + .await + .unwrap() + .into_inner(); + assert_eq!( + result.status, + pb::batch_mutate_response::Status::Failed as i32, + "{result:?}" + ); + assert!(result.failure.iter().any(|v| v.code == "ENTITY_REFERENCED")); + let details = format!("{result:?}"); + assert!(!details.contains(&hidden)); + assert!(!details.contains("hidden-contract")); + } + let references = client + .get_incoming_references(authed( + pb::GetReferencesRequest { + kind: pb::EntityKind::ReadModel as i32, + id: Some(target), + ..Default::default() + }, + true, + )) + .await + .unwrap() + .into_inner(); + assert!(references.references.is_empty(), "{references:?}"); + let mut forbidden = declaration("owned-contract"); + forbidden.id.as_mut().unwrap().namespace = own; + forbidden.dependencies.push(pb::ContractDependency { + target: Some(pb::contract_dependency::Target::Specification( + pb::SpecificationRef { + id: Some(pb::Id { + namespace: hidden.clone(), + slug: "hidden-contract".into(), + version: 1, + }), + }, + )), + ..Default::default() + }); + let error = client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(entity(forbidden)), + ..Default::default() + }, + false, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::NotFound, "{error:?}"); + assert!(!error.message().contains("hidden-contract")); +} + +#[tokio::test] +async fn authenticated_reader_sees_unresolved_provider_without_hidden_target_details_or_reclassification( +) { + let service = service().await; + let own = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "owned-contracts".into(), + parent: "owned".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let hidden = service + .register_namespace(Request::new(pb::RegisterNamespaceRequest { + name: "hidden-provider".into(), + parent: "other".into(), + })) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let provider_id = pb::Id { + namespace: hidden.clone(), + slug: "secret-provider-key".into(), + version: 1, + }; + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::Component(pb::Component { + id: Some(provider_id.clone()), + title: "Secret provider display".into(), + doc: "Secret provider documentation".into(), + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap(); + let mut value = declaration("visible-api"); + value.id.as_mut().unwrap().namespace = own.clone(); + let first = save(&service, value).await; + let mut value = spec(&first); + value.operations.push(pb::ContractOperation { + name: Some(name("Notify")), + messages: vec![pb::OperationMessage { + message_id: value.messages[0].member_id.clone(), + role: pb::operation_message::Role::Notification as i32, + ..Default::default() + }], + ..Default::default() + }); + let next = save(&service, value).await; + let mut value = spec(&next); + value.exposures.push(pb::ProviderExposure { + provider: Some(pb::ProviderTarget { + target: Some(pb::provider_target::Target::Component(pb::ComponentRef { + id: Some(provider_id.clone()), + })), + }), + operation_id: value.operations[0].member_id.clone(), + ..Default::default() + }); + let api = save(&service, value).await; + assert!(is_api(&spec(&api))); + let mut client = authenticated_client(service).await; + let error = client + .get_entity(authed( + pb::GetEntityRequest { + kind: pb::EntityKind::Component as i32, + id: Some(provider_id), + }, + true, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::NotFound); + let visible = client + .get_entity(authed( + pb::GetEntityRequest { + kind: pb::EntityKind::Specification as i32, + id: spec(&api).id.clone(), + }, + true, + )) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + assert!(is_api(&spec(&visible))); + let report = client + .validate_project(authed( + pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace(own)), + }, + true, + )) + .await + .unwrap() + .into_inner(); + assert!(report + .reports + .iter() + .flat_map(|v| &v.issues) + .any(|v| v.code == "SPECIFICATION_REFERENCE_UNRESOLVED")); + let rendered = format!("{report:?}"); + for secret in [ + &hidden, + "secret-provider-key", + "Secret provider display", + "Secret provider documentation", + ] { + assert!(!rendered.contains(secret), "{rendered}"); + } + let write = client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(visible), + ..Default::default() + }, + true, + )) + .await + .unwrap_err(); + assert_eq!(write.code(), Code::PermissionDenied); +} diff --git a/rsworkspace/crates/trogon-atlas-store/fuzz/Cargo.lock b/rsworkspace/crates/trogon-atlas-store/fuzz/Cargo.lock index 0f4c9973..a7842a21 100644 --- a/rsworkspace/crates/trogon-atlas-store/fuzz/Cargo.lock +++ b/rsworkspace/crates/trogon-atlas-store/fuzz/Cargo.lock @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1513,9 +1513,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -2031,14 +2031,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/rsworkspace/crates/trogon-atlas-store/fuzz/osv-scanner.toml b/rsworkspace/crates/trogon-atlas-store/fuzz/osv-scanner.toml new file mode 100644 index 00000000..3e6389b8 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-store/fuzz/osv-scanner.toml @@ -0,0 +1,6 @@ +[[PackageOverrides]] +name = "webpki-roots" +version = "0.26.11" +ecosystem = "crates.io" +vulnerability.ignore = true +reason = "Compatibility shim only reexports webpki-roots 1.x; the locked 1.0.10 certificate data is scanned separately." diff --git a/rsworkspace/crates/trogon-atlas-store/src/key.rs b/rsworkspace/crates/trogon-atlas-store/src/key.rs index 0341b68e..b015bc88 100644 --- a/rsworkspace/crates/trogon-atlas-store/src/key.rs +++ b/rsworkspace/crates/trogon-atlas-store/src/key.rs @@ -346,6 +346,7 @@ fn kind_from_short(s: &str) -> Option { "type_library" => EntityKind::TypeLibrary, "channel" => EntityKind::Channel, "data_store" => EntityKind::DataStore, + "specification" => EntityKind::Specification, _ => return None, }) } @@ -366,7 +367,11 @@ mod tests { #[test] fn resource_keys_roundtrip_through_baseline_and_branch_keys() { - for kind in [EntityKind::Channel, EntityKind::DataStore] { + for kind in [ + EntityKind::Channel, + EntityKind::DataStore, + EntityKind::Specification, + ] { let id = id("shop", "order-feed", 2); assert_eq!( parse_entity_key(&entity_key(kind, &id)), diff --git a/rsworkspace/crates/trogon-atlas-store/src/nats.rs b/rsworkspace/crates/trogon-atlas-store/src/nats.rs index 11f61661..838ffc90 100644 --- a/rsworkspace/crates/trogon-atlas-store/src/nats.rs +++ b/rsworkspace/crates/trogon-atlas-store/src/nats.rs @@ -2145,6 +2145,7 @@ fn kind_of(entity: &Entity) -> Option { EntityOneof::TypeLibrary(_) => EntityKind::TypeLibrary, EntityOneof::Channel(_) => EntityKind::Channel, EntityOneof::DataStore(_) => EntityKind::DataStore, + EntityOneof::Specification(_) => EntityKind::Specification, }) } @@ -2181,6 +2182,7 @@ fn entity_id(entity: &Entity) -> Option<&Id> { EntityOneof::TypeLibrary(x) => x.id.as_ref(), EntityOneof::Channel(x) => x.id.as_ref(), EntityOneof::DataStore(x) => x.id.as_ref(), + EntityOneof::Specification(x) => x.id.as_ref(), } } diff --git a/rsworkspace/crates/trogon-atlas-store/tests/reference_guards.rs b/rsworkspace/crates/trogon-atlas-store/tests/reference_guards.rs index ad732375..d03955fe 100644 --- a/rsworkspace/crates/trogon-atlas-store/tests/reference_guards.rs +++ b/rsworkspace/crates/trogon-atlas-store/tests/reference_guards.rs @@ -300,3 +300,150 @@ async fn multi_target_reference_guard_uses_one_merged_world() { Err(StoreError::NotFound) )); } + +#[tokio::test] +async fn specification_nested_references_guard_the_complete_target_set_and_branch_tombstones() { + use std::collections::HashSet; + + let nats = trogon_atlas_testsupport::shared().await; + let store = nats.store().await; + let targets = [ + (pb::EntityKind::ReadModel, "receipt"), + (pb::EntityKind::Schema, "native-shape"), + (pb::EntityKind::TypeLibrary, "types"), + (pb::EntityKind::Specification, "shared-contract"), + (pb::EntityKind::Channel, "transport"), + (pb::EntityKind::Component, "provider"), + (pb::EntityKind::ExternalSystem, "caller"), + ] + .map(|(kind, slug)| pb::EntityKey::new(kind, &id(slug))); + let entity = pb::Entity { + kind: Some(pb::entity::Kind::Specification(pb::Specification { + id: Some(id("order-contract")), + title: "Order contract".into(), + schemas: vec![pb::ContractSchema { + definition: Some(pb::SchemaDefinition { + target: Some(pb::schema_definition::Target::Schema(pb::SchemaRef { + id: Some(id("native-shape")), + })), + ..Default::default() + }), + ..Default::default() + }], + messages: vec![pb::ContractMessage { + mapping: Some(pb::MessageReadModelMapping { + read_model: Some(pb::ReadModelRef { + id: Some(id("receipt")), + }), + ..Default::default() + }), + schemas: vec![ + pb::SchemaBinding { + r#type: Some(pb::schema_binding::Type::LibraryType( + pb::TypeLibraryTypeRef { + library: Some(pb::TypeLibraryRef { + id: Some(id("types")), + }), + message: Some(pb::QualifiedProtoName { + value: "orders.Receipt".into(), + }), + }, + )), + ..Default::default() + }, + pb::SchemaBinding { + r#type: Some(pb::schema_binding::Type::ContractType( + pb::ContractTypeRef { + schema: Some(pb::ContractMemberRef { + specification: Some(pb::SpecificationRef { + id: Some(id("shared-contract")), + }), + owner_uid: "selected-incarnation".into(), + kind: pb::ContractMemberKind::Schema as i32, + member_id: Some(pb::ContractMemberId { + value: "11111111-1111-4111-8111-111111111111".into(), + }), + ..Default::default() + }), + ..Default::default() + }, + )), + ..Default::default() + }, + ], + ..Default::default() + }], + operations: vec![pb::ContractOperation { + binding: Some(pb::OperationBinding { + kind: Some(pb::operation_binding::Kind::Channel( + pb::ChannelOperationBinding { + channel: Some(pb::ChannelRef { + id: Some(id("transport")), + }), + ..Default::default() + }, + )), + }), + ..Default::default() + }], + exposures: vec![pb::ProviderExposure { + provider: Some(pb::ProviderTarget { + target: Some(pb::provider_target::Target::Component(pb::ComponentRef { + id: Some(id("provider")), + })), + }), + ..Default::default() + }], + usages: vec![pb::ApiUsage { + caller: Some(pb::ProviderTarget { + target: Some(pb::provider_target::Target::ExternalSystem( + pb::ExternalSystemRef { + id: Some(id("caller")), + }, + )), + }), + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }; + store + .create( + pb::EntityKind::Specification, + &entity, + WriteContext::baseline(), + ) + .await + .unwrap(); + assert_eq!( + store.referenced_targets(&targets, None).await.unwrap(), + HashSet::from(targets.clone()) + ); + store.create_branch("contract-removal", "").await.unwrap(); + assert_eq!( + store + .referenced_targets(&targets, Some("contract-removal")) + .await + .unwrap(), + HashSet::from(targets.clone()) + ); + store + .delete( + pb::EntityKind::Specification, + &id("order-contract"), + None, + WriteContext::branch("contract-removal"), + ) + .await + .unwrap(); + assert!(store + .referenced_targets(&targets, Some("contract-removal")) + .await + .unwrap() + .is_empty()); + assert_eq!( + store.referenced_targets(&targets, None).await.unwrap(), + HashSet::from(targets) + ); +} diff --git a/rsworkspace/deny.toml b/rsworkspace/deny.toml index 3dcea93f..3bf7a971 100644 --- a/rsworkspace/deny.toml +++ b/rsworkspace/deny.toml @@ -5,6 +5,7 @@ targets = [] ignore = [ # Fix needs lru 0.18, which tantivy 0.26 does not allow yet; blocked on a tantivy release that bumps lru. "RUSTSEC-2026-0253", + { id = "RUSTSEC-2026-0381", reason = "0.26.11 only reexports patched 1.x roots; bans below reject every affected version except this data-free shim" }, ] [licenses] @@ -30,6 +31,10 @@ wildcards = "deny" # Workspace path dependencies carry no version requirement by design; only # external wildcard requirements are a hygiene problem. allow-wildcard-paths = true +deny = [ + { name = "webpki-roots", version = "<0.26.11", reason = "certificate data must exclude distrusted emSign roots" }, + { name = "webpki-roots", version = ">0.26.11, <1.0.10", reason = "only the data-free 0.26.11 shim may precede patched certificate data" }, +] # Benign duplicate crate versions pulled in by different versions of # third-party dependencies. These are not security concerns; skip them # until the direct deps align on a common version. diff --git a/rsworkspace/osv-scanner.toml b/rsworkspace/osv-scanner.toml index c754de98..bbee9478 100644 --- a/rsworkspace/osv-scanner.toml +++ b/rsworkspace/osv-scanner.toml @@ -1,3 +1,10 @@ [[IgnoredVulns]] id = "RUSTSEC-2026-0253" reason = "fix needs lru 0.18, which tantivy 0.26 does not allow yet; blocked on a tantivy release that bumps lru." + +[[PackageOverrides]] +name = "webpki-roots" +version = "0.26.11" +ecosystem = "crates.io" +vulnerability.ignore = true +reason = "Compatibility shim only reexports webpki-roots 1.x; the locked 1.0.10 certificate data is scanned separately." diff --git a/tsworkspace/webapps/studio/server/devops-contract.test.mjs b/tsworkspace/webapps/studio/server/devops-contract.test.mjs index 03e36c93..b2431b68 100644 --- a/tsworkspace/webapps/studio/server/devops-contract.test.mjs +++ b/tsworkspace/webapps/studio/server/devops-contract.test.mjs @@ -58,6 +58,21 @@ describe('proto entrypoint contract', () => { expect(typeof ctor).toBe('function'); }); + it('loads the native Specification union and exact nested content through the deployed service package', () => { + const definitions = protoLoader.loadSync(path.join(protoRoot, SERVICE_PKG_DIR, 'service.proto'), { + keepCase: false, longs: String, enums: String, defaults: false, oneofs: false, includeDirs: [protoRoot], + }); + const entityCodec = definitions['trogonatlas.eventmodel.v1alpha1.Entity']; + const kind = definitions['trogonatlas.eventmodel.v1alpha1.EntityKind']; + expect(kind.type.value).toContainEqual(expect.objectContaining({ name: 'ENTITY_KIND_SPECIFICATION', number: 32 })); + const entity = { specification: { + id: { namespace: 'shop', slug: 'contract', version: '2' }, title: 'Order contract', + artifacts: [{ memberId: { value: '11111111-1111-4111-8111-111111111111' }, name: { value: 'Source' }, content: '{"type":"object"}\r\n', digest: { sha256: 'a'.repeat(64) } }], + messages: [{ memberId: { value: '22222222-2222-4222-8222-222222222222' }, name: { value: 'Receipt' }, mapping: { connectionId: { value: '33333333-3333-4333-8333-333333333333' }, readModel: { id: { namespace: 'shop', slug: 'receipt', version: '3' } } } }], + } }; + expect(entityCodec.deserialize(entityCodec.serialize(entity))).toEqual(entity); + }); + it('event_model.proto does not expose EventModelService (must not be PROTO_PATH)', () => { // service definitions live in service.proto; using event_model.proto as // PROTO_PATH leaves EventModelService undefined at studio boot. diff --git a/tsworkspace/webapps/studio/src/components/ArchitectureResourceDetails.tsx b/tsworkspace/webapps/studio/src/components/ArchitectureResourceDetails.tsx index dd037ba0..f1ae7339 100644 --- a/tsworkspace/webapps/studio/src/components/ArchitectureResourceDetails.tsx +++ b/tsworkspace/webapps/studio/src/components/ArchitectureResourceDetails.tsx @@ -28,7 +28,7 @@ function Row({ label, children }: { label: string; children: ReactNode }) { ); } -function ExactReference({ +export function ExactReference({ kind, reference, model, diff --git a/tsworkspace/webapps/studio/src/components/EventModelPage.test.tsx b/tsworkspace/webapps/studio/src/components/EventModelPage.test.tsx index 2d08faaa..c72a0108 100644 --- a/tsworkspace/webapps/studio/src/components/EventModelPage.test.tsx +++ b/tsworkspace/webapps/studio/src/components/EventModelPage.test.tsx @@ -206,6 +206,7 @@ describe('EventModelPage: as of a changeset', () => { cleanup(); vi.clearAllMocks(); capturedShell = {}; + capturedIssues = undefined; (api.eventModel as ReturnType).mockResolvedValue({ entities: [] }); (api.validate as ReturnType).mockResolvedValue({ issues: [] }); }); @@ -265,6 +266,34 @@ describe('EventModelPage: as of a changeset', () => { }); }); + it('loads baseline history before the URL update flushes', async () => { + vi.useFakeTimers(); + try { + window.history.replaceState({}, '', '/em/acme/checkout?branch=alex/rework'); + await act(async () => { + render(); + }); + expect(capturedShell.onAsOfChange).toBeTypeOf('function'); + + await act(async () => { + (capturedShell.onAsOfChange as (id: string) => void)(AS_OF); + }); + + expect(new URLSearchParams(window.location.search).get('branch')).toBe('alex/rework'); + expect(api.eventModel).toHaveBeenLastCalledWith( + 'acme', + 'checkout', + expect.objectContaining({ asOf: AS_OF, branch: undefined }), + ); + await act(async () => vi.runAllTimersAsync()); + expect(new URLSearchParams(window.location.search).get('asOf')).toBe(AS_OF); + expect(new URLSearchParams(window.location.search).has('branch')).toBe(false); + } finally { + await act(async () => vi.runAllTimersAsync()); + vi.useRealTimers(); + } + }); + it('never renders the previous board against the new as-of while it loads', async () => { render(); await waitFor(() => { @@ -297,12 +326,23 @@ describe('EventModelPage: as of a changeset', () => { }); it('drops the as-of validation note the moment the board returns to live', async () => { + let resolveModel!: (value: { entities: unknown[] }) => void; + (api.eventModel as ReturnType).mockImplementation( + () => + new Promise<{ entities: unknown[] }>((resolve) => { + resolveModel = resolve; + }), + ); window.history.replaceState({}, '', `/em/acme/checkout?asOf=${AS_OF}`); render(); + expect(capturedIssues).toBeUndefined(); + expect(capturedShell.onAsOfChange).toBeUndefined(); + await act(async () => resolveModel({ entities: [] })); await waitFor(() => { expect(capturedIssues?.error).toMatch(/past changeset/); }); + (api.eventModel as ReturnType).mockResolvedValue({ entities: [] }); (api.validate as ReturnType).mockReturnValue(new Promise(() => {})); await act(async () => (capturedShell.onAsOfChange as (id: string | undefined) => void)(undefined)); diff --git a/tsworkspace/webapps/studio/src/components/EventModelPage.tsx b/tsworkspace/webapps/studio/src/components/EventModelPage.tsx index dc27d54d..b8f0ab5f 100644 --- a/tsworkspace/webapps/studio/src/components/EventModelPage.tsx +++ b/tsworkspace/webapps/studio/src/components/EventModelPage.tsx @@ -45,7 +45,7 @@ export function EventModelPage({ ctrlRef.current?.abort(); const ctrl = new AbortController(); ctrlRef.current = ctrl; - const branch = readBranchFromUrl(); + const branch = asOf ? undefined : readBranchFromUrl(); api .eventModel(namespace, slug, { signal: ctrl.signal, branch, ...(asOf ? { asOf } : {}) }) .then((res) => { diff --git a/tsworkspace/webapps/studio/src/components/Inspector.tsx b/tsworkspace/webapps/studio/src/components/Inspector.tsx index 3887a34b..4bd0fbed 100644 --- a/tsworkspace/webapps/studio/src/components/Inspector.tsx +++ b/tsworkspace/webapps/studio/src/components/Inspector.tsx @@ -1,6 +1,7 @@ import { ExternalLink, FileCode2, Split, TriangleAlert, X } from 'lucide-react'; import { useMemo, useState } from 'react'; import { ArchitectureResourceDetails } from '@/components/ArchitectureResourceDetails'; +import { SpecificationDetails } from '@/components/SpecificationDetails'; import { CopyContextButton } from '@/components/CopyContextButton'; import { EntityHistory } from '@/components/EntityHistory'; import { ExampleBlock } from '@/components/ExampleBlock'; @@ -629,6 +630,7 @@ export function Inspector({ ) : null} + {entity.kind === 'typeLibrary' ? : null} {(() => { diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.test.ts b/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.test.ts index 29c7526d..54250ebe 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.test.ts +++ b/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.test.ts @@ -34,6 +34,7 @@ const ALL_ENTITY_KINDS: EntityKind[] = [ 'typeLibrary', 'channel', 'dataStore', + 'specification', ]; describe('KIND_SHORT_TO_CAMEL registry completeness', () => { diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.ts b/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.ts index e2819660..c62f417e 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.ts +++ b/tsworkspace/webapps/studio/src/components/ModelShell.kindmap.ts @@ -31,4 +31,5 @@ export const KIND_SHORT_TO_CAMEL: Record = { type_library: 'typeLibrary', channel: 'channel', data_store: 'dataStore', + specification: 'specification', }; diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx index c3785fde..9e328af4 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx @@ -79,6 +79,7 @@ const KIND_SHORT: Record = { typeLibrary: 'type_library', channel: 'channel', dataStore: 'data_store', + specification: 'specification', }; function isOverviewMode(value: string | null): value is OverviewMode { diff --git a/tsworkspace/webapps/studio/src/components/Sidebar.tsx b/tsworkspace/webapps/studio/src/components/Sidebar.tsx index 87ef3c5b..accde2ca 100644 --- a/tsworkspace/webapps/studio/src/components/Sidebar.tsx +++ b/tsworkspace/webapps/studio/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ const LEGEND: { kind: Entity['kind']; label: string; swatch: string }[] = [ { kind: 'externalSystem', label: 'External System', swatch: 'bg-fuchsia-200' }, { kind: 'channel', label: 'Channel', swatch: 'bg-cyan-100' }, { kind: 'dataStore', label: 'DataStore', swatch: 'bg-emerald-100' }, + { kind: 'specification', label: 'Specification', swatch: 'bg-slate-100' }, { kind: 'tracker', label: 'Tracker', swatch: 'bg-stone-200' }, { kind: 'boundedContext', label: 'Bounded Context', swatch: 'bg-fuchsia-100' }, { kind: 'domain', label: 'Domain', swatch: 'bg-indigo-100' }, diff --git a/tsworkspace/webapps/studio/src/components/SpecificationDetails.test.tsx b/tsworkspace/webapps/studio/src/components/SpecificationDetails.test.tsx new file mode 100644 index 00000000..5d5f9c67 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/SpecificationDetails.test.tsx @@ -0,0 +1,247 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildModel, wireKind, type Entity, type Model } from '@/lib/model'; +import { isApiSpecification, resolveContractType } from '@/lib/specification'; +import { SpecificationDetails } from './SpecificationDetails'; + +const id = (slug: string, version = '1') => ({ namespace: 'orders', slug, version }); +const memberId = (value: string) => ({ value }); +const digest = { sha256: 'a'.repeat(64) }; +function fixture() { + return buildModel([ + { readModel: { id: id('request'), title: 'Request data' } }, + { readModel: { id: id('receipt', '2'), title: 'Receipt data' } }, + { readModel: { id: id('receipt', '3'), title: 'New receipt data' } }, + { component: { id: id('public'), title: 'Public gateway' } }, + { component: { id: id('partner'), title: 'Partner gateway' } }, + { component: { id: id('checkout'), title: 'Checkout' } }, + { + system: { uid: 'contract-owner' }, + specification: { + id: id('order-api'), + title: 'Order API', + artifacts: [ + { + memberId: memberId('artifact'), + name: memberId('Contract source'), + path: memberId('contracts/order.json'), + format: { known: 'KNOWN_JSON_SCHEMA' }, + mediaType: memberId('application/json'), + content: '{\r\n "type": "object"\r\n}\r\n', + digest, + }, + ], + schemas: [ + { + memberId: memberId('shape'), + name: memberId('Order shape'), + definition: { + connectionId: memberId('definition'), + artifact: { artifactId: memberId('artifact'), expectedDigest: digest }, + selector: { syntax: 'json-pointer', value: '/$defs/Order' }, + }, + }, + ], + messages: [ + { + memberId: memberId('request'), + name: memberId('Order request'), + schemas: [ + { connectionId: memberId('request-shape'), role: 'ROLE_PAYLOAD', localSchema: memberId('shape') }, + ], + mapping: { connectionId: memberId('request-map'), readModel: { id: id('request') } }, + }, + { + memberId: memberId('response'), + name: memberId('Order receipt'), + mapping: { connectionId: memberId('response-map'), readModel: { id: id('receipt', '2') } }, + }, + { memberId: memberId('unmapped'), name: memberId('Unmapped message') }, + ], + operations: [ + { + memberId: memberId('submit'), + name: memberId('Submit'), + binding: { http: { method: 'POST', path: '/orders', responseStatusCodes: [201] } }, + messages: [ + { connectionId: memberId('request-occurrence'), messageId: memberId('request'), role: 'ROLE_REQUEST' }, + { connectionId: memberId('response-occurrence'), messageId: memberId('response'), role: 'ROLE_RESPONSE' }, + { + connectionId: memberId('notification-occurrence'), + messageId: memberId('response'), + role: 'ROLE_NOTIFICATION', + }, + ], + }, + { + memberId: memberId('lookup'), + name: memberId('Lookup'), + messages: [ + { connectionId: memberId('another-response'), messageId: memberId('response'), role: 'ROLE_RESPONSE' }, + ], + }, + ], + exposures: [ + { + connectionId: memberId('public-exposure'), + provider: { component: { id: id('public') } }, + operationId: memberId('submit'), + }, + { + connectionId: memberId('partner-exposure'), + provider: { component: { id: id('partner') } }, + operationId: memberId('submit'), + }, + ], + usages: [ + { + connectionId: memberId('checkout-usage'), + caller: { component: { id: id('checkout') } }, + operationId: memberId('submit'), + exposureId: memberId('public-exposure'), + }, + { + connectionId: memberId('unspecified-usage'), + caller: { component: { id: id('checkout') } }, + operationId: memberId('submit'), + }, + ], + }, + }, + ]); +} +function specification(model: Model): Entity { + const entity = model.byKind.get('specification')?.[0]; + if (!entity) throw new Error('Specification fixture missing'); + return entity; +} +function typeSelection() { + return { + schema: { + specification: { id: id('order-api') }, + ownerUid: 'contract-owner', + kind: 'CONTRACT_MEMBER_KIND_SCHEMA', + memberId: memberId('shape'), + }, + expectedArtifactId: memberId('artifact'), + expectedDigest: digest, + expectedSelector: { syntax: 'json-pointer', value: '/$defs/Order' }, + }; +} +afterEach(cleanup); + +describe('Specification review', () => { + it('normalizes the native kind and derives API classification without adding behavior', () => { + const model = fixture(); + const entity = specification(model); + expect(wireKind('ENTITY_KIND_SPECIFICATION')).toBe('specification'); + expect(model.slices).toHaveLength(0); + expect(isApiSpecification(entity)).toBe(true); + expect(isApiSpecification({ ...entity, raw: { ...entity.raw, exposures: [] } })).toBe(false); + expect(isApiSpecification({ ...entity, raw: { ...entity.raw, operations: [] } })).toBe(false); + expect( + isApiSpecification({ ...entity, raw: { ...entity.raw, exposures: [{ operationId: memberId('unknown') }] } }), + ).toBe(false); + const restrictedProjection = buildModel([{ system: { uid: entity.uid }, specification: entity.raw }]); + expect(isApiSpecification(specification(restrictedProjection))).toBe(true); + }); + it('renders retained content, unverified selectors, separate roles and exact mapped versions without edit controls', () => { + const model = fixture(); + const onSelect = vi.fn(); + const { container } = render( + , + ); + expect(screen.getByRole('heading', { name: 'API specification' })).toBeDefined(); + expect(container.querySelector('pre')?.textContent).toBe('{\r\n "type": "object"\r\n}\r\n'); + expect(screen.getByText('json-pointer: /$defs/Order (unverified)')).toBeDefined(); + expect(screen.getByText('REQUEST')).toBeDefined(); + expect(screen.getAllByText('RESPONSE')).toHaveLength(2); + expect(screen.getByText('NOTIFICATION')).toBeDefined(); + for (const identity of ['request-occurrence', 'response-occurrence', 'notification-occurrence', 'another-response']) + expect(screen.getByText(identity)).toBeDefined(); + expect(screen.getByText('ReadModel mapping undeclared')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'orders/receipt@2' })); + expect(onSelect).toHaveBeenCalledWith('readModel:orders/receipt@2'); + expect(screen.queryByRole('button', { name: 'orders/receipt@3' })).toBeNull(); + expect(container.querySelectorAll('input, textarea, select, [contenteditable="true"]')).toHaveLength(0); + }); + it('shows only the selected provider per usage and keeps provider absence explicit', () => { + const model = fixture(); + render(); + const usage = screen.getByText('checkout-usage').closest('dl'); + if (!usage) throw new Error('Usage declaration missing'); + expect(within(usage).getByRole('button', { name: 'orders/public@1' })).toBeDefined(); + expect(within(usage).queryByRole('button', { name: 'orders/partner@1' })).toBeNull(); + const unspecified = screen.getByText('unspecified-usage').closest('dl'); + if (!unspecified) throw new Error('Unspecified usage declaration missing'); + expect(within(unspecified).getByText('Provider undeclared')).toBeDefined(); + expect(within(unspecified).queryByRole('button', { name: /orders\/(public|partner)@1/ })).toBeNull(); + }); + it('renders native, library and external pinned schema bindings independently of transport declarations', () => { + const model = fixture(); + const original = specification(model); + const selected = { + ...original, + raw: { + ...original.raw, + messages: [ + { + memberId: memberId('bindings'), + name: memberId('Typed message'), + schemas: [ + { + connectionId: memberId('native-binding'), + role: 'ROLE_HEADERS', + schema: { id: id('native-shape', '2') }, + }, + { + connectionId: memberId('library-binding'), + role: 'ROLE_PAYLOAD', + libraryType: { library: { id: id('types') }, message: memberId('orders.Receipt') }, + }, + { connectionId: memberId('external-binding'), role: 'ROLE_PAYLOAD', contractType: typeSelection() }, + ], + }, + ], + operations: [ + { + memberId: memberId('transport-operation'), + name: memberId('Transport operation'), + binding: { channel: { connectionId: memberId('channel-binding'), channel: { id: id('transport') } } }, + messages: [], + }, + ], + }, + }; + const onSelect = vi.fn(); + render(); + expect(screen.getByText('orders/native-shape@2 (not loaded)')).toBeDefined(); + expect(screen.getByText('orders/types@1 (not loaded)')).toBeDefined(); + expect(screen.getByText('orders.Receipt', { exact: false })).toBeDefined(); + expect(screen.getByText('channel-binding')).toBeDefined(); + expect(screen.getByText('orders/transport@1 (not loaded)')).toBeDefined(); + const external = screen.getByText('external-binding').closest('dl'); + if (!external) throw new Error('External binding missing'); + expect(within(external).getByText('Order shape')).toBeDefined(); + expect(within(external).getByText('json-pointer: /$defs/Order (unverified)')).toBeDefined(); + fireEvent.click(within(external).getByRole('button', { name: 'orders/order-api@1' })); + expect(onSelect).toHaveBeenCalledWith('specification:orders/order-api@1'); + }); + it('resolves external schema pins by owner incarnation, member identity, digest and selector presence', () => { + const model = fixture(); + const selection = typeSelection(); + expect(resolveContractType(model, selection)?.name).toEqual(memberId('Order shape')); + for (const stale of [ + { ...selection, schema: { ...selection.schema, ownerUid: 'retired-owner' } }, + { ...selection, schema: { ...selection.schema, ownerUid: '' } }, + { ...selection, schema: { ...selection.schema, expectedDigest: digest } }, + { ...selection, schema: { ...selection.schema, kind: 'CONTRACT_MEMBER_KIND_MESSAGE' } }, + { ...selection, schema: { ...selection.schema, memberId: memberId('other') } }, + { ...selection, expectedArtifactId: memberId('replacement') }, + { ...selection, expectedDigest: { sha256: 'b'.repeat(64) } }, + { ...selection, expectedSelector: undefined }, + { ...selection, expectedSelector: { syntax: 'json-pointer', value: '/$defs/Other' } }, + ]) + expect(resolveContractType(model, stale)).toBeUndefined(); + }); +}); diff --git a/tsworkspace/webapps/studio/src/components/SpecificationDetails.tsx b/tsworkspace/webapps/studio/src/components/SpecificationDetails.tsx new file mode 100644 index 00000000..0c7f9d47 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/SpecificationDetails.tsx @@ -0,0 +1,272 @@ +import type { ReactNode } from 'react'; +import { ExactReference } from '@/components/ArchitectureResourceDetails'; +import { type Entity, type Model } from '@/lib/model'; +import { + contractList as list, + contractRecord as record, + contractValue as value, + contractMember, + isApiSpecification, + resolveContractType, + type ContractRecord, +} from '@/lib/specification'; + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} +function Occurrence({ item, children }: { item: ContractRecord; children: ReactNode }) { + return ( +
+ {value(item.connectionId)} + {children} + {item.doc !== undefined ? {value(item.doc)} : null} +
+ ); +} +function Member({ item, children }: { item: ContractRecord; children: ReactNode }) { + return ( +
+

{value(item.name)}

+
+ {value(item.memberId)} + {item.doc !== undefined ? {value(item.doc)} : null} +
+ {children} +
+ ); +} +function LocalMember({ owner, collection, memberId }: { owner: Entity; collection: string; memberId: unknown }) { + const member = contractMember(owner, collection, memberId); + return ( + + {value(memberId)} + {member ? ` (${value(member.name)})` : ' (unresolved member)'} + + ); +} +function Selector({ selector }: { selector: unknown }) { + return ( + + {selector === undefined + ? 'Whole artifact (unverified)' + : `${value(record(selector).syntax)}: ${value(record(selector).value)} (unverified)`} + + ); +} + +export function SpecificationDetails({ + entity, + model, + onSelect, +}: { + entity: Entity; + model: Model; + onSelect: (key: string) => void; +}) { + if (entity.kind !== 'specification') return null; + const raw = entity.raw; + const reference = (kind: Parameters[0]['kind'], target: unknown) => ( + + ); + const provider = (target: unknown) => { + const p = record(target); + return p.component + ? reference('component', p.component) + : p.externalSystem + ? reference('externalSystem', p.externalSystem) + : 'Undeclared'; + }; + const schemaType = (binding: ContractRecord) => { + if (binding.localSchema) return ; + if (binding.schema) return reference('schema', binding.schema); + if (binding.libraryType) + return ( + <> + {reference('typeLibrary', record(binding.libraryType).library)}: {value(record(binding.libraryType).message)} + + ); + if (binding.contractType) { + const selection = record(binding.contractType); + const ref = record(selection.schema); + const resolved = resolveContractType(model, selection); + return ( +
+ {reference('specification', ref.specification)} + Owner UID: {value(ref.ownerUid)} + Schema member: {value(ref.memberId)} + {resolved ? value(resolved.name) : 'Not resolved in the loaded model'} + Artifact member: {value(selection.expectedArtifactId)} + SHA-256: {value(record(selection.expectedDigest).sha256)} +
+ +
+
+ ); + } + return 'Undeclared'; + }; + return ( +
+

+ {isApiSpecification(entity) ? 'API specification' : 'Specification'} +

+

+ Declarations describe contracts and dependencies. They do not establish runtime traffic or execution. +

+

Retained artifacts

+ {list(raw.artifacts).map((artifact) => ( + +
+ {value(artifact.path)} + + {record(artifact.format).other !== undefined + ? value(record(artifact.format).other) + : value(record(artifact.format).known).replace('KNOWN_', '')} + + {value(artifact.formatVersion)} + {value(artifact.mediaType)} + {value(record(artifact.digest).sha256)} +
+
+ Retained source text +
{value(artifact.content)}
+
+
+ ))} +

Schemas

+ {list(raw.schemas).map((schema) => { + const definition = record(schema.definition); + return ( + + + {definition.artifact ? ( + <> + + + + {value(record(record(definition.artifact).expectedDigest).sha256)} + + + ) : ( + {reference('schema', definition.schema)} + )} + + + ); + })} +

Messages

+ {list(raw.messages).map((message) => ( + + {message.mapping ? ( + + {reference('readModel', record(message.mapping).readModel)} + + ) : ( +

ReadModel mapping undeclared

+ )} + {!list(message.schemas).length ?

Message shape undeclared

: null} + {list(message.schemas).map((binding) => ( + + {value(binding.role).replace('ROLE_', '')} + {schemaType(binding)} + + ))} +
+ ))} +

Operations

+ {list(raw.operations).map((operation) => { + const binding = record(operation.binding); + return ( + +
+ {binding.http ? ( + + {value(record(binding.http).method)} {value(record(binding.http).path)} + {Array.isArray(record(binding.http).responseStatusCodes) + ? ` (${(record(binding.http).responseStatusCodes as unknown[]).join(', ')})` + : ''} + + ) : binding.rpc ? ( + + {value(record(binding.rpc).service)} / {value(record(binding.rpc).method)} + + ) : binding.graphql ? ( + + {value(record(binding.graphql).kind).replace('KIND_', '')} {value(record(binding.graphql).field)} + + ) : binding.other ? ( + + {value(record(binding.other).protocol)}: {value(record(binding.other).description)} + + ) : !binding.channel ? ( + Undeclared + ) : null} +
+ {binding.channel ? ( + + {reference('channel', record(binding.channel).channel)} + + ) : null} + {list(operation.messages).map((occurrence) => ( + + {value(occurrence.role).replace('ROLE_', '')} + + + + + ))} +
+ ); + })} +

Provider exposures

+ {!list(raw.exposures).length ?

Provider undeclared

: null} + {list(raw.exposures).map((exposure) => ( + + {provider(exposure.provider)} + + + + + ))} +

API usages

+ {list(raw.usages).map((usage) => { + const exposure = list(raw.exposures).find( + (candidate) => + value(candidate.connectionId) === value(usage.exposureId) && + value(candidate.operationId) === value(usage.operationId), + ); + return ( + + {provider(usage.caller)} + + + + {usage.exposureId ? value(usage.exposureId) : 'Provider undeclared'} + {usage.exposureId ? ( + {exposure ? provider(exposure.provider) : 'Unresolved exposure'} + ) : null} + + ); + })} +

Dependencies

+ {list(raw.dependencies).map((dependency) => ( + + + {dependency.specification + ? reference('specification', dependency.specification) + : reference('typeLibrary', dependency.typeLibrary)} + + + ))} +
+ ); +} diff --git a/tsworkspace/webapps/studio/src/components/canvas/StickyNode.tsx b/tsworkspace/webapps/studio/src/components/canvas/StickyNode.tsx index 9cf85d67..f041ce83 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/StickyNode.tsx +++ b/tsworkspace/webapps/studio/src/components/canvas/StickyNode.tsx @@ -86,6 +86,7 @@ const KIND_STYLE: Record< label: 'Alert Notification Target', icon: Megaphone, }, + specification: { bg: 'bg-slate-100', border: 'border-slate-400', label: 'Specification', icon: ListChecks }, typeLibrary: { bg: 'bg-slate-100', border: 'border-slate-400', label: 'Type Library', icon: ListChecks }, }; diff --git a/tsworkspace/webapps/studio/src/components/canvas/kindStyle.coverage.test.ts b/tsworkspace/webapps/studio/src/components/canvas/kindStyle.coverage.test.ts index 1c39fe19..3f2af5f6 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/kindStyle.coverage.test.ts +++ b/tsworkspace/webapps/studio/src/components/canvas/kindStyle.coverage.test.ts @@ -33,6 +33,7 @@ const ALL_KINDS: EntityKind[] = [ 'alertNotificationTarget', 'channel', 'dataStore', + 'specification', 'typeLibrary', ]; diff --git a/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts b/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts index 9b9e28d5..81c5298c 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts +++ b/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts @@ -36,6 +36,7 @@ const KINDS: Record = { typeLibrary: true, channel: true, dataStore: true, + specification: true, }; describe('kindColors', () => { diff --git a/tsworkspace/webapps/studio/src/lib/model.ts b/tsworkspace/webapps/studio/src/lib/model.ts index e0ef3608..c7f13c44 100644 --- a/tsworkspace/webapps/studio/src/lib/model.ts +++ b/tsworkspace/webapps/studio/src/lib/model.ts @@ -53,7 +53,8 @@ export type EntityKind = | 'alertNotificationTarget' | 'typeLibrary' | 'channel' - | 'dataStore'; + | 'dataStore' + | 'specification'; export interface FieldTypeConfig { version?: number; @@ -430,6 +431,7 @@ const KIND_SET: ReadonlySet = new Set([ 'typeLibrary', 'channel', 'dataStore', + 'specification', ]); const WIRE_KIND: Record = { @@ -464,6 +466,7 @@ const WIRE_KIND: Record = { ENTITY_KIND_TYPE_LIBRARY: 'typeLibrary', ENTITY_KIND_CHANNEL: 'channel', ENTITY_KIND_DATA_STORE: 'dataStore', + ENTITY_KIND_SPECIFICATION: 'specification', }; export function wireKind(wire: unknown): EntityKind | undefined { diff --git a/tsworkspace/webapps/studio/src/lib/specification.ts b/tsworkspace/webapps/studio/src/lib/specification.ts new file mode 100644 index 00000000..d2258371 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/specification.ts @@ -0,0 +1,67 @@ +import { asId, type Entity, entityKey, type Model } from '@/lib/model'; + +export type ContractRecord = Record; +export function contractRecord(value: unknown): ContractRecord { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as ContractRecord) : {}; +} +export function contractList(value: unknown): ContractRecord[] { + return Array.isArray(value) ? value.map(contractRecord) : []; +} +export function contractValue(value: unknown): string { + if (value === undefined || value === null) return 'Undeclared'; + return typeof value === 'object' ? contractValue(contractRecord(value).value) : String(value); +} +export function isApiSpecification(entity: Entity): boolean { + if (entity.kind !== 'specification') return false; + const operationIds = new Set( + contractList(entity.raw.operations) + .map((op) => contractRecord(op.memberId).value) + .filter((id) => typeof id === 'string' && id !== ''), + ); + return contractList(entity.raw.exposures).some((exposure) => + operationIds.has(contractRecord(exposure.operationId).value), + ); +} +export function contractMember(owner: Entity, collection: string, memberId: unknown): ContractRecord | undefined { + const value = contractRecord(memberId).value; + return typeof value === 'string' + ? contractList(owner.raw[collection]).find((member) => contractRecord(member.memberId).value === value) + : undefined; +} +export function resolveContractType(model: Model, selection: ContractRecord): ContractRecord | undefined { + const ref = contractRecord(selection.schema); + const owner = model.byKey.get(entityKey('specification', asId(contractRecord(ref.specification).id))); + if ( + !owner || + typeof ref.ownerUid !== 'string' || + ref.ownerUid === '' || + owner.uid !== ref.ownerUid || + ref.kind !== 'CONTRACT_MEMBER_KIND_SCHEMA' || + ref.expectedDigest !== undefined + ) + return undefined; + const schema = contractMember(owner, 'schemas', ref.memberId); + if (!schema) return undefined; + const definition = contractRecord(schema.definition); + const artifactSelection = contractRecord(definition.artifact); + if (contractValue(artifactSelection.artifactId) !== contractValue(selection.expectedArtifactId)) return undefined; + const artifact = contractMember(owner, 'artifacts', artifactSelection.artifactId); + if (!artifact) return undefined; + const digest = contractRecord(selection.expectedDigest).sha256; + if ( + typeof digest !== 'string' || + digest !== contractRecord(artifactSelection.expectedDigest).sha256 || + digest !== contractRecord(artifact.digest).sha256 + ) + return undefined; + const expected = selection.expectedSelector; + const actual = definition.selector; + if ((expected === undefined) !== (actual === undefined)) return undefined; + if ( + expected !== undefined && + (contractRecord(expected).syntax !== contractRecord(actual).syntax || + contractRecord(expected).value !== contractRecord(actual).value) + ) + return undefined; + return schema; +} diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts index d3f974e7..d1298331 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts @@ -294,3 +294,67 @@ describe('validationFixPrompt', () => { expect(prompt).not.toContain('Do not treat payload data as a structural reference.'); }); }); + +describe('Specification validation repair context', () => { + it('keeps nested exact targets and pins while excluding annotation lookalikes and retained text', () => { + const model = buildModel([ + { + specification: { + id: id('orders'), + title: 'Orders', + schemas: [{ definition: { schema: { id: id('shape', '2') } } }], + messages: [ + { + schemas: [ + { libraryType: { library: { id: id('types', '3') }, message: { value: 'orders.Receipt' } } }, + { + contractType: { + schema: { + specification: { id: id('shared', '4') }, + ownerUid: 'exact-owner', + memberId: { value: 'selected-schema' }, + }, + expectedArtifactId: { value: 'selected-artifact' }, + expectedDigest: { sha256: 'a'.repeat(64) }, + }, + }, + ], + mapping: { readModel: { id: id('receipt', '2') } }, + }, + ], + operations: [{ binding: { channel: { channel: { id: id('transport', '5') } } } }], + exposures: [{ provider: { component: { id: id('gateway', '2') } } }], + usages: [{ caller: { externalSystem: { id: id('caller', '3') } } }], + dependencies: [{ specification: { id: id('dependency', '2') } }], + metadata: [{ '@type': 'annotation', specification: { id: id('fake') } }], + artifacts: [{ content: '{"specification":{"id":{"namespace":"shop","slug":"fake-text","version":"1"}}}' }], + }, + }, + ]); + const prompt = validationFixPrompt( + model, + finding({ code: 'SPECIFICATION_REFERENCE_UNRESOLVED', subject: { kind: 'specification', id: id('orders') } }), + ); + expect(snapshot(prompt, 'References to fetch')).toEqual({ + notLoaded: [ + 'schema:shop/shape@2', + 'typeLibrary:shop/types@3', + 'specification:shop/shared@4', + 'readModel:shop/receipt@2', + 'channel:shop/transport@5', + 'component:shop/gateway@2', + 'externalSystem:shop/caller@3', + 'specification:shop/dependency@2', + ], + loadedButOmitted: [], + }); + expect(snapshot(prompt, 'Loaded subject and related definitions')).toEqual([ + expect.objectContaining({ + identity: 'specification:shop/orders@1', + raw: expect.objectContaining({ messages: expect.any(Array) }), + }), + ]); + expect(prompt).toContain('exact-owner'); + expect(prompt).toContain('selected-artifact'); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.ts b/tsworkspace/webapps/studio/src/lib/validation-context.ts index 3c97cfeb..d755a18a 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.ts @@ -24,6 +24,13 @@ const referenceFields: Record = { upstream: 'boundedContext', realizes: 'subdomain', domain: 'domain', + specification: 'specification', + schema: 'schema', + channel: 'channel', + library: 'typeLibrary', + typeLibrary: 'typeLibrary', + component: 'component', + externalSystem: 'externalSystem', }; function identity(ref: ContextRef): string { @@ -50,7 +57,8 @@ function references(entity: Entity): ContextRef[] { const kind = wireKind(body.kind) ?? impliedKind; if (kind && body.id) add(kind, body.id); for (const [field, child] of Object.entries(body)) { - if (['id', 'data', 'metadata', 'fields', 'schema', 'supersedes'].includes(field)) continue; + if (['id', 'data', 'metadata', 'fields', 'supersedes'].includes(field)) continue; + if (field === 'schema' && entity.kind !== 'specification') continue; visit(child, referenceFields[field]); } };