diff --git a/docs/README.md b/docs/README.md index 3ba3e577..9ebbe74a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ Resource contracts and acceptance: - [Architecture resource coverage](reference/architecture-resource-coverage.md) - [Domain-scoped listing](reference/domain-scoped-listing.md) -- [System relationship contract proposal](reference/system-relationship-contracts.md) +- [System relationship contracts](reference/system-relationship-contracts.md) - [System relationship acceptance requirements](reference/system-relationship-acceptance.md) - [Channel and DataStore contracts](reference/channel-and-datastore-contracts.md) - [Channel and DataStore acceptance fixture](reference/channel-and-datastore-acceptance.md) diff --git a/docs/explanation/architecture-resource-roadmap.md b/docs/explanation/architecture-resource-roadmap.md index 101e6b33..2344e6b3 100644 --- a/docs/explanation/architecture-resource-roadmap.md +++ b/docs/explanation/architecture-resource-roadmap.md @@ -124,15 +124,16 @@ selectors require explicit rebinding. The typed boundary and read-only review surface. Remote connectors and additional format compilers remain separate milestones. -## Proposed system relationship layer +## Native system relationship layer -The [system relationship proposal](../reference/system-relationship-contracts.md) +The [system relationship contract](../reference/system-relationship-contracts.md) builds on existing Component and ExternalSystem boundaries. Its [acceptance requirements](../reference/system-relationship-acceptance.md) keep authored declarations distinct from checked behavioral paths and runtime -evidence. This is a specification milestone, not native implementation or -completed acceptance. Richer grouping and actor-interaction boundaries remain -explicit in the proposal. +evidence. Native schema, admission, exact occurrence identity, authoring and +inspection are implemented. The acceptance reference links executable coverage +without claiming exhaustive or deployed acceptance. Richer grouping and +actor-interaction boundaries remain explicit in the contract. ## Atlas Linter comes after the architecture resources diff --git a/docs/reference/architecture-resource-coverage.md b/docs/reference/architecture-resource-coverage.md index 4b8e92b1..32f6e5ae 100644 --- a/docs/reference/architecture-resource-coverage.md +++ b/docs/reference/architecture-resource-coverage.md @@ -10,7 +10,7 @@ relationship, executable behavior, or a passing client workflow. See the | Resource | Supported fields and selections | Contract and executable coverage | | --- | --- | --- | | Strategic scope | Project, Domain, Subdomain and BoundedContext records; project/domain references, realized subdomains and directed context agreements. | [Strategic schema](../../proto/trogonatlas/eventmodel/v1alpha1/strategic.proto), [reference extraction](../../rsworkspace/crates/trogon-atlas-core/src/refs.rs). | -| Component and ExternalSystem | Component technology and curated exact entity membership; external boundary descriptions, ReadModel external-source links and Processor calls. | [Infrastructure schema](../../proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto), [reference extraction](../../rsworkspace/crates/trogon-atlas-core/src/refs.rs). | +| Component and ExternalSystem | Component technology and curated exact entity membership; external boundary descriptions, ReadModel external-source links and Processor calls; identified directed architectural declarations between exact boundaries. | [Infrastructure schema](../../proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto), [reference extraction](../../rsworkspace/crates/trogon-atlas-core/src/refs.rs), [relationship contract](system-relationship-contracts.md) and [acceptance](system-relationship-acceptance.md). | | Channel | Address parameters, protocols, delivery declarations, optional broker and bindings to exact AutomationSlice observation occurrences. | [Contract](channel-and-datastore-contracts.md), [acceptance](channel-and-datastore-acceptance.md). | | DataStore | Store class, technology, purpose, authority, access mode, classification, residency, retention and identified ReadModel attachments. | [Contract](channel-and-datastore-contracts.md), [acceptance](channel-and-datastore-acceptance.md). | | Specification | Retained artifacts, schema/message/operation members, individual message mappings, operation roles, provider exposures and usages. | [Contract](specification-and-api-contracts.md), [acceptance](specification-and-api-acceptance.md). | @@ -31,7 +31,7 @@ version strings do not replace these selectors. See | Area | Existing representation | Remaining boundary | | --- | --- | --- | | Strategic membership | Domain project, Subdomain domain and context realizations are native references. [Domain-scoped listing](domain-scoped-listing.md) preserves exact strategic versions and considers every realization; ordinary entity scope remains namespace-derived. | Arbitrary domain nesting and shared authored system/service membership have no dedicated fields. Listing does not establish authored membership, change project-validation scope or add a Domain version selector to Studio. | -| System and service relationships | Component members can select other Components and ExternalSystems, with ordinary reference discovery. | The [system relationship proposal](system-relationship-contracts.md) and [acceptance requirements](system-relationship-acceptance.md) specify a bounded declaration layer; they do not establish implemented support. Dedicated grouping levels and system-level actor interactions remain separate boundaries. Containment is Component-owned; ExternalSystem has no members field, so external-system-owned membership also remains outside current support. | +| System and service relationships | Component members can select other Components and ExternalSystems. Both boundary kinds support [identified directed architectural declarations](system-relationship-contracts.md), exact subject selection and reference discovery; [acceptance](system-relationship-acceptance.md) separates executable coverage from runtime claims. | Dedicated grouping levels and system-level actor interactions remain separate boundaries. Containment is Component-owned; ExternalSystem has no members field, so external-system-owned membership also remains outside current support. Labels establish no containment, message flow or runtime dependency. | | Communication | API roles/providers/usages, Processor calls and Channel bindings to actual observations are supported. | General authored send/receive and conditional-trigger declarations before behavioral mapping have no native relationship contract. Retained source declarations do not establish executable paths. | | Metadata and agents | Registered annotations include links, code repository/path/symbol/ref provenance, tags and lifecycle status. | Lifecycle effective dates and agent model/tool declarations need dedicated contracts. Arbitrary metadata references are not recursively indexed. See [annotations](../../proto/trogonatlas/annotation/v1alpha1/annotations.proto). | | Business data | Schema fields, nested shapes, schema type references and identity-derivation annotations are available. | Business identifier roles, foreign entity/property selections, relationship labels and general aggregate intent are not supplied by type reuse. See [field schema](../../proto/trogonatlas/eventmodel/v1alpha1/fields.proto). | diff --git a/docs/reference/system-relationship-acceptance.md b/docs/reference/system-relationship-acceptance.md index e39d833d..35a629b2 100644 --- a/docs/reference/system-relationship-acceptance.md +++ b/docs/reference/system-relationship-acceptance.md @@ -1,9 +1,10 @@ # System relationship acceptance requirements -Status: proposed executable acceptance requirements for the -[contract](system-relationship-contracts.md), not implemented and not run. -Logical fixture notation below is not current manifest syntax or an available -RPC payload. No backend, CLI/MCP, Studio or runtime acceptance is claimed. +Status: native support for the [contract](system-relationship-contracts.md) is +implemented. This reference defines acceptance requirements and links executable +coverage below. Logical fixture notation is not verbatim manifest syntax or an +RPC payload. Linked service, client and component tests establish different +boundaries; they do not establish deployment or runtime behavior. ## Fixture and evidence @@ -140,3 +141,24 @@ Studio inspection/browser evidence, when supplied, must identify the native backend, fixture and exact selectors. Mocked transport does not establish native persistence. Runtime observations require separate deployment/instrumentation evidence; no runtime proof is needed or supplied by this declaration contract. + +## Executable coverage inventory + +The implementation fixtures below exercise different boundaries. A fixture's +presence is source evidence; its test result must be recorded separately. + +| Fixture | Covered boundary | +| --- | --- | +| [Core relationship tests](../../rsworkspace/crates/trogon-atlas-core/tests/architectural_relationships.rs) | Value validation, exact typed endpoints, occurrence inventory and lifecycle, retargeting, subject resolution and Source ownership restrictions. | +| [Backend relationship tests](../../rsworkspace/crates/trogon-atlas-server/tests/system_relationship_backend.rs) | Service persistence, atomic final-overlay admission, reference provenance, safe/force deletion, branch lifecycle and nested discovery budgets. | +| [Authenticated composed fixture](../../rsworkspace/crates/trogon-atlas-server/tests/system_relationship_acceptance.rs) | Public gRPC with a real Store, parallel declarations alongside raw membership, forward/reverse provenance and exact Document/Person subjects preserved through reorder and wording edits. | +| [Source backend tests](../../rsworkspace/crates/trogon-atlas-server/tests/source_integration_backend.rs) | Native-only isolation during Source creation and updates, with retained local relationships. | +| [Client manifest tests](../../rsworkspace/crates/trogon-atlas-client/tests/architectural_relationship_manifest.rs) | Explicit endpoint shape, native manifest decoding and preservation. | +| [CLI integration tests](../../rsworkspace/crates/trogon-atlas-cli/tests/architectural_relationship_cli_integration.rs) | Native manifest authoring through the CLI. | +| [MCP integration tests](../../rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs) | Native tool authoring and exact query selectors through MCP. | +| [Studio declaration tests](../../tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.test.tsx) | Loaded-view declaration inspection, exact endpoint navigation and parallel occurrences, using component fixtures. | + +These fixtures do not add abstract grouping, ExternalSystem-owned membership +or actor aggregation. Studio component fixtures do not establish a deployed +browser workflow or native persistence. Their aggregate presence does not +establish that every requirement above has been executed. diff --git a/docs/reference/system-relationship-contracts.md b/docs/reference/system-relationship-contracts.md index e65c3f24..5cdaf29b 100644 --- a/docs/reference/system-relationship-contracts.md +++ b/docs/reference/system-relationship-contracts.md @@ -1,9 +1,10 @@ # System and service relationship contracts -Status: proposed native contract, not implemented. This reference specifies -backend requirements; it supplies no schema changes, executable acceptance -results or runtime evidence. [Acceptance requirements](system-relationship-acceptance.md) -remain unrun. Existing capabilities below are grounded in current source. +Status: native relationship support is implemented in the schema, core, backend, +CLI/MCP authoring and Studio inspection. The +[infrastructure schema](../../proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto) +is the wire contract. [Acceptance requirements and executable coverage](system-relationship-acceptance.md) +distinguish service, client and component fixtures from deployment/runtime evidence. ## Scope and existing representations @@ -23,7 +24,7 @@ and other implementation boundaries, with shared exact members remaining shared. It does not establish exclusive ownership, runtime hosting, arbitrary abstract system containment, a business hierarchy or transitive membership. A reference to an ExternalSystem does not mean the Component deploys that third party. -This proposal does not change membership shape, admissible kinds, ordering, +This layer does not change membership shape, admissible kinds, ordering, duplicate handling or existing validation. It adds no membership ConnectionRef. | Boundary | Disposition | @@ -34,14 +35,13 @@ duplicate handling or existing validation. It adds no membership ConnectionRef. | System-level human interactions | Deferred. Existing slice occurrences can ground human behavior, but raw membership cannot identify which hosting assertion supports an interaction. A new aggregation/interaction selector and its lifecycle need a separate contract. | | General send/receive, conditional triggers and agent/tool communication | Deferred. A label may retain architectural intent but does not declare message flow, processor execution or event production. Existing typed API/observation paths retain their own meanings. | -## Proposed wire shape +## Wire shape -The following names and tags are proposed implementation input, not current -protobuf fields. Extend existing Component with `relationships` at field 8 and -ExternalSystem with `relationships` at field 7. Both are repeated +Component carries `relationships` at field 8 and ExternalSystem at field 7. +Both are repeated `ArchitecturalRelationship` values. Existing fields retain their meanings. -| Proposed message/field | Type and value contract | +| Message/field | Type and value contract | | --- | --- | | `ArchitecturalRelationship.connection_id = 1` | Existing `ConnectionId`; required in persisted state, omitted for assignment as described below. | | `ArchitecturalRelationship.target = 2` | Required `ArchitecturalEndpoint`. | @@ -129,7 +129,7 @@ reported independently, with ordinary edited-owner reference admission retained. | Surface | Required extension | | --- | --- | -| Native authoring | Exact reads, guarded put/batch, validation, diff, branch operations, preview, native manifest/export and snapshot recovery preserve the proposed fields and returned identities. Advertise a new writer contract revision and mutation floor before admitting fields older writers could drop; no revision number is allocated here. | +| Native authoring | Exact reads, guarded put/batch, validation, diff, branch operations, preview, native manifest/export and snapshot recovery preserve the fields and returned identities. Contract revision and mutation floors are 16; older writers are refused before they can drop relationships. | | Outgoing/incoming references | Add explicit extraction for `relationships[i].target.component` or `.external_system`, and return existing [Reference](../../proto/trogonatlas/api/eventmodel/v1alpha1/graph.proto) fields `from`, `from_uid`, `connection_id`, `from_field`, `to`. Preserve parallel occurrences and multiplicity. A path is diagnostic only. | | Subject discovery | ListSubjectAssignments, ListSubjectDecisions and ListSubjectDocuments match full ConnectionRef, preserving owner UID, occurrence ID and unchanged selector. Existing namespace visibility/budgets apply. New shapes must be explicitly included in scan/index provenance enrichment, not assumed indexed from protobuf reflection. | | Impact | Include declared target references with full occurrence provenance on returned paths. Existing owner-level reachability/one-path-per-owner behavior is not exhaustive parallel-path enumeration or a runtime dependency classifier. | @@ -162,7 +162,7 @@ in the same store is not proof of portable migration. ## Admission, visibility and bounds -All following rules are proposed requirements for this slice. Backend authority, +The following rules govern this native slice. Backend authority, existing namespace access, write guards, metadata and entity size/RPC limits remain applicable. No catalog label or assignment grants security authority. @@ -196,7 +196,7 @@ Repeated equal ReadModel edges must remain distinct; repeated storyboard moments must retain authored order and recurrence without invented moment IDs. Component membership alone cannot choose an interaction occurrence or prove -hosting, a human's permissions or runtime execution. This proposal adds no +hosting, a human's permissions or runtime execution. This layer adds no Persona-to-system link, interaction message, inferred actor or behavioral query. The absent aggregation/hosting occurrence contract is the reason for deferral; existing exact slice inspection remains available. UI navigation and Screen diff --git a/proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto b/proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto index d697d852..a5b33d1d 100644 --- a/proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto +++ b/proto/trogonatlas/eventmodel/v1alpha1/infrastructure.proto @@ -5,6 +5,24 @@ package trogonatlas.eventmodel.v1alpha1; import "google/protobuf/any.proto"; import "trogonatlas/type/v1alpha1/id.proto"; import "trogonatlas/eventmodel/v1alpha1/identity.proto"; +import "trogonatlas/eventmodel/v1alpha1/reliability.proto"; +import "trogonatlas/eventmodel/v1alpha1/decision_document.proto"; +import "trogonatlas/type/v1alpha1/connection_id.proto"; + +message ArchitecturalEndpoint { + oneof target { + ComponentRef component = 1; + ExternalSystemRef external_system = 2; + } +} + +message ArchitecturalRelationship { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + ArchitecturalEndpoint target = 2; + CatalogTitle label = 3; + CatalogText doc = 4; +} + // A Component is a living implementation unit: the deployable thing that // hosts processors, serves UIs, projects read models, exposes APIs, and owns // its technology choices -- including the messaging it relies on (Kafka @@ -20,6 +38,7 @@ message Component { repeated EntityRef members = 5; // what it hosts (processors, UIs, read models, ...) repeated google.protobuf.Any metadata = 6; trogonatlas.type.v1alpha1.Id supersedes = 7; + repeated ArchitecturalRelationship relationships = 8; } // An ExternalSystem is a living third-party boundary: the APIs and webhooks @@ -33,6 +52,7 @@ message ExternalSystem { repeated string tech = 4; // e.g. "webhook", "rest", "kafka" repeated google.protobuf.Any metadata = 5; trogonatlas.type.v1alpha1.Id supersedes = 6; + repeated ArchitecturalRelationship relationships = 7; } // Delivery status of a tracked item. Lives ONLY in Trackers -- design diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/architectural_relationship_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/architectural_relationship_cli_integration.rs new file mode 100644 index 00000000..f373e1ba --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-cli/tests/architectural_relationship_cli_integration.rs @@ -0,0 +1,180 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::{process::Command, sync::Arc}; + +use tokio::net::TcpListener; +use trogon_atlas_proto::event_model_service_server::EventModelServiceServer; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +async fn start_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let store = trogon_atlas_testsupport::shared().await.store().await; + let service = + EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(EventModelServiceServer::new(service)) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + format!("http://{addr}") +} +fn cli(endpoint: &str, args: &[&str]) -> String { + let result = Command::new(env!("CARGO_BIN_EXE_trogon-atlas")) + .arg("--endpoint") + .arg(endpoint) + .args(args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn architectural_cli_authoring_export_reapply_and_exact_subject_queries() { + use serde_json::json; + use trogon_atlas_proto as pb; + let endpoint = start_server().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("architecture.yaml"); + std::fs::write( + &path, + r"apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ExternalSystem +metadata: {namespace: cli-architecture, name: provider, version: 9007199254740993} +spec: {title: Provider} +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Component +metadata: {namespace: cli-architecture, name: checkout} +spec: + title: Checkout + relationships: + - target: {externalSystem: provider@9007199254740993} + label: {value: submits orders} + doc: {value: Architectural intent} + - target: {externalSystem: provider@9007199254740993} + label: {value: submits orders} + doc: {value: Architectural intent} +", + ) + .unwrap(); + cli(&endpoint, &["apply", "-f", path.to_str().unwrap()]); + let before = cli(&endpoint, &["get", "component/cli-architecture/checkout@1"]); + let entity = trogon_atlas_client::manifest::parse_str(&before, "read.yaml") + .unwrap() + .remove(0) + .entity; + let relations = trogon_atlas_core::architectural_relationships::relationships(&entity); + let ids: Vec<_> = relations + .iter() + .map(|item| item.connection_id.as_ref().unwrap().value.clone()) + .collect(); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + let mut native = + pb::event_model_service_client::EventModelServiceClient::connect(endpoint.clone()) + .await + .unwrap(); + let response = native + .get_entity(pb::GetEntityRequest { + kind: pb::EntityKind::Component as i32, + id: Some(pb::Id { + namespace: "cli-architecture".into(), + slug: "checkout".into(), + version: 1, + }), + }) + .await + .unwrap() + .into_inner(); + let owner_uid = response + .entity + .as_ref() + .unwrap() + .system + .as_ref() + .unwrap() + .uid + .clone(); + let subject = |value: &str| json!({"connection":{"owner":{"kind":"ENTITY_KIND_COMPONENT","id":{"namespace":"cli-architecture","slug":"checkout","version":"1"}},"ownerUid":owner_uid,"id":{"value":value}}}); + let team = json!({"apiVersion":trogon_atlas_client::manifest::API_VERSION,"kind":"Team","metadata":{"namespace":"cli-architecture","name":"team"},"spec":{"title":{"value":"Operators"},"responsibilities":[{"kind":"RESPONSIBILITY_KIND_CONTACT","subject":subject(&ids[0])}]}}); + let team_path = dir.path().join("team.yaml"); + std::fs::write(&team_path, team.to_string()).unwrap(); + cli(&endpoint, &["apply", "-f", team_path.to_str().unwrap()]); + for (index, value) in ids.iter().enumerate() { + let result: serde_json::Value = serde_json::from_str(&cli( + &endpoint, + &[ + "list-subject-assignments", + "--request", + &json!({"subject":subject(value)}).to_string(), + ], + )) + .unwrap(); + assert_eq!( + result["assignments"].as_array().map_or(0, Vec::len), + usize::from(index == 0), + "{result}" + ); + assert!(result["scope"].is_object()); + } + for request in [ + json!({"subject":{"connection":{"ownerUid":false}}}), + json!({"subject":{"connection":{"path":"relationships[0]"}}}), + ] { + let output = Command::new(env!("CARGO_BIN_EXE_trogon-atlas")) + .args([ + "--endpoint", + &endpoint, + "list-subject-assignments", + "--request", + &request.to_string(), + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + } + let described = cli( + &endpoint, + &["describe", "component/cli-architecture/checkout@1"], + ); + for value in &ids { + assert!(described.contains(value)); + } + let export = dir.path().join("export"); + cli( + &endpoint, + &[ + "export", + "--namespace", + "cli-architecture", + "--output", + export.to_str().unwrap(), + ], + ); + cli(&endpoint, &["diff", "-f", export.to_str().unwrap()]); + cli(&endpoint, &["apply", "-f", export.to_str().unwrap()]); + let after = native + .get_entity(pb::GetEntityRequest { + kind: pb::EntityKind::Component as i32, + id: Some(pb::Id { + namespace: "cli-architecture".into(), + slug: "checkout".into(), + version: 1, + }), + }) + .await + .unwrap() + .into_inner(); + assert_eq!(after, response); + assert_eq!( + before, + cli(&endpoint, &["get", "component/cli-architecture/checkout@1"]) + ); +} diff --git a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json index 01f892b8..a51d54a6 100644 --- a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json +++ b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json @@ -289,6 +289,46 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.ArchitecturalEndpoint": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "component", + "externalSystem" + ] + } + } + ], + "properties": { + "component": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ComponentRef" + }, + "externalSystem": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ExternalSystemRef" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.ArchitecturalRelationship": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "label": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + }, + "target": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ArchitecturalEndpoint" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.ArtifactPath": { "additionalProperties": false, "properties": { @@ -1128,6 +1168,12 @@ }, "type": "array" }, + "relationships": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ArchitecturalRelationship" + }, + "type": "array" + }, "supersedes": { "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" }, @@ -2790,6 +2836,12 @@ }, "type": "array" }, + "relationships": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ArchitecturalRelationship" + }, + "type": "array" + }, "supersedes": { "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" }, diff --git a/rsworkspace/crates/trogon-atlas-client/src/manifest.rs b/rsworkspace/crates/trogon-atlas-client/src/manifest.rs index 91b79ae2..81b0fa11 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/manifest.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/manifest.rs @@ -438,7 +438,8 @@ pub(crate) fn shorthand_ref_field( ) -> Option { if matches!( md.full_name(), - "trogonatlas.eventmodel.v1alpha1.ChannelAccess" + "trogonatlas.eventmodel.v1alpha1.ArchitecturalEndpoint" + | "trogonatlas.eventmodel.v1alpha1.ChannelAccess" | "trogonatlas.eventmodel.v1alpha1.StoreAccess" | "trogonatlas.eventmodel.v1alpha1.ResponsibilitySubject" | "trogonatlas.eventmodel.v1alpha1.CatalogSubject" diff --git a/rsworkspace/crates/trogon-atlas-client/tests/architectural_relationship_manifest.rs b/rsworkspace/crates/trogon-atlas-client/tests/architectural_relationship_manifest.rs new file mode 100644 index 00000000..9e677920 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-client/tests/architectural_relationship_manifest.rs @@ -0,0 +1,129 @@ +#![allow(clippy::unwrap_used)] + +use serde_json::json; +use trogon_atlas_client::{apply, export, manifest, manifest_schema}; +use trogon_atlas_core::transcode::TranscodePool; +use trogon_atlas_proto as pb; + +fn raw(kind: &str, relationships: &serde_json::Value) -> serde_json::Value { + json!({"apiVersion":manifest::API_VERSION,"kind":kind,"metadata":{"namespace":"architecture","name":"checkout","version":"18446744073709551615"},"spec":{"title":"Checkout","relationships":relationships}}) +} +fn declarations() -> serde_json::Value { + json!([ + {"connectionId":{"value":"01930000-0000-7000-8000-000000000001"},"target":{"externalSystem":"provider@9007199254740993"},"label":{"value":"submits orders"},"doc":{"value":"Cafe\u{301}\r\n"}}, + {"connectionId":{"value":"01930000-0000-7000-8000-000000000002"},"target":{"externalSystem":"provider@9007199254740993"},"label":{"value":"submits orders"},"doc":{"value":"Cafe\u{301}\r\n"}}, + {"connectionId":{"value":"01930000-0000-7000-8000-000000000003"},"target":{"component":"worker@2"},"label":{"value":"contains"}} + ]) +} +#[test] +fn native_relationships_roundtrip_parallel_ids_exact_versions_wording_and_optional_doc() { + let types = TranscodePool::builtin().unwrap(); + let validator = jsonschema::validator_for(&manifest_schema::generate().unwrap()).unwrap(); + for kind in ["Component", "ExternalSystem"] { + let input = raw(kind, &declarations()); + assert!(validator.is_valid(&input)); + let loaded = manifest::parse_str(&input.to_string(), "architecture.yaml").unwrap(); + let entity = &loaded[0].entity; + let relationships = trogon_atlas_core::architectural_relationships::relationships(entity); + assert_eq!(relationships.len(), 3); + assert_ne!( + relationships[0].connection_id, + relationships[1].connection_id + ); + assert_eq!(relationships[2].doc, None); + let endpoint = trogon_atlas_core::architectural_relationships::endpoint_ref( + relationships[0].target.as_ref().unwrap(), + ) + .unwrap(); + assert_eq!(endpoint.id.unwrap().version, 9_007_199_254_740_993); + assert_eq!(loaded[0].id.version, u64::MAX); + let output = export::render_board(std::slice::from_ref(entity), &[], &types).unwrap(); + assert_eq!(output.skipped, []); + let reparsed = manifest::parse_str(&output.manifests, "export.yaml").unwrap(); + assert_eq!(reparsed[0].entity, *entity); + assert_eq!( + apply::classify(&reparsed[0].entity, Some(entity)), + apply::Action::Unchanged + ); + let mut reordered = declarations(); + reordered.as_array_mut().unwrap().reverse(); + let changed = + manifest::parse_str(&raw(kind, &reordered).to_string(), "reordered.yaml").unwrap(); + assert_eq!( + apply::classify(&changed[0].entity, Some(entity)), + apply::Action::Configure + ); + } +} +#[test] +fn native_relationship_structure_rejects_ambiguous_targets_and_unknown_fields() { + for target in [ + json!("provider@1"), + json!({"component":"worker","externalSystem":"provider"}), + json!({"entity":{"kind":"component","id":"worker"}}), + ] { + let input = raw( + "Component", + &json!([{"target":target,"label":{"value":"uses"}}]), + ); + assert!(manifest::parse_str(&input.to_string(), "invalid.yaml").is_err()); + assert!( + !jsonschema::validator_for(&manifest_schema::generate().unwrap()) + .unwrap() + .is_valid(&input) + ); + } +} +#[test] +fn architectural_subject_requests_preserve_full_connection_ref_and_reject_coercion() { + let types = TranscodePool::builtin().unwrap(); + for kind in ["ENTITY_KIND_COMPONENT", "ENTITY_KIND_EXTERNAL_SYSTEM"] { + let input = json!({"subject":{"connection":{"owner":{"kind":kind,"id":{"namespace":"architecture","slug":"checkout","version":"18446744073709551615"}},"ownerUid":"opaque-owner-UID","id":{"value":"01930000-0000-7000-8000-000000000001"}}}}); + let request: pb::ListSubjectAssignmentsRequest = + trogon_atlas_client::responsibility::parse_request( + "ListSubjectAssignmentsRequest", + &input, + ) + .unwrap(); + assert_eq!( + types + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.ListSubjectAssignmentsRequest", + &request + ) + .unwrap(), + input + ); + for invalid in [ + json!({"subject":{"connection":{"ownerUid":false}}}), + json!({"subject":{"connection":{},"entity":{}}}), + json!({"unknown":true}), + ] { + assert!(trogon_atlas_client::responsibility::parse_request::< + pb::ListSubjectAssignmentsRequest, + >("ListSubjectAssignmentsRequest", &invalid) + .is_err()); + } + } +} + +#[test] +fn generic_description_retains_incoming_parallel_declaration_provenance() { + let types = TranscodePool::builtin().unwrap(); + let entity: pb::Entity = types.message_from_json_value("trogonatlas.eventmodel.v1alpha1.Entity", &json!({"externalSystem":{"id":{"namespace":"architecture","slug":"provider","version":"9007199254740993"},"title":"Provider"}})).unwrap(); + let references: Vec<_> = ["01930000-0000-7000-8000-000000000001", "01930000-0000-7000-8000-000000000002"].iter().enumerate().map(|(index, value)| json!({"from":{"kind":"ENTITY_KIND_COMPONENT","id":{"namespace":"architecture","slug":"checkout","version":"18446744073709551615"}},"fromUid":"opaque-owner-UID","fromField":format!("relationships[{index}].target.external_system"),"to":{"kind":"ENTITY_KIND_EXTERNAL_SYSTEM","id":{"namespace":"architecture","slug":"provider","version":"9007199254740993"}},"connectionId":{"value":value}})).collect(); + let typed = references + .iter() + .map(|value| { + types + .message_from_json_value("trogonatlas.api.eventmodel.v1alpha1.Reference", value) + .unwrap() + }) + .collect::>(); + let description = trogon_atlas_client::view::describe(&entity, &types, &[], &typed).unwrap(); + let rendered = serde_json::to_value(description).unwrap(); + assert_eq!(rendered["incoming"].as_array().unwrap().len(), 2); + for (index, reference) in references.iter().enumerate() { + assert_eq!(rendered["incoming"][index]["provenance"], *reference); + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/architectural_relationships.rs b/rsworkspace/crates/trogon-atlas-core/src/architectural_relationships.rs new file mode 100644 index 00000000..e80ecb6d --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/architectural_relationships.rs @@ -0,0 +1,179 @@ +use prost::Message; +use trogon_atlas_proto::{self as pb, entity, Entity, EntityKind, EntityRef}; + +use crate::{connections, entity_id, entity_kind, refs::OutboundRef}; + +pub const MAX_RELATIONSHIPS: usize = 1_024; +pub const MAX_ENCODED_BYTES: usize = 1_048_576; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[error("{0}")] +pub struct ArchitecturalRelationshipError(pub String); + +type Result = std::result::Result; + +#[must_use] +pub fn relationships(entity: &Entity) -> &[pb::ArchitecturalRelationship] { + match &entity.kind { + Some(entity::Kind::Component(value)) => &value.relationships, + Some(entity::Kind::ExternalSystem(value)) => &value.relationships, + _ => &[], + } +} + +#[must_use] +pub fn endpoint_ref(endpoint: &pb::ArchitecturalEndpoint) -> Option { + use pb::architectural_endpoint::Target; + let (kind, id) = match endpoint.target.as_ref()? { + Target::Component(value) => (EntityKind::Component, value.id.clone()), + Target::ExternalSystem(value) => (EntityKind::ExternalSystem, value.id.clone()), + }; + Some(EntityRef { + kind: kind as i32, + id, + }) +} + +fn require(valid: bool, message: &str) -> Result<()> { + if valid { + Ok(()) + } else { + Err(ArchitecturalRelationshipError(message.into())) + } +} + +pub fn validate_values(entity: &Entity) -> Result<()> { + if !matches!( + entity_kind(entity), + Some(EntityKind::Component | EntityKind::ExternalSystem) + ) { + return Ok(()); + } + require( + entity.encoded_len() <= MAX_ENCODED_BYTES, + "architectural owner exceeds encoded byte limit", + )?; + let owner = entity_id(entity) + .ok_or_else(|| ArchitecturalRelationshipError("exact owner ID required".into()))?; + exact(Some(owner))?; + require( + relationships(entity).len() <= MAX_RELATIONSHIPS, + "architectural relationship limit exceeded", + )?; + for value in relationships(entity) { + if let Some(id) = &value.connection_id { + id.value + .parse::() + .map_err(|e| ArchitecturalRelationshipError(e.to_string()))?; + } + let target = value + .target + .as_ref() + .and_then(endpoint_ref) + .ok_or_else(|| { + ArchitecturalRelationshipError("architectural target required".into()) + })?; + exact(target.id.as_ref())?; + require( + entity_kind(entity).map(|kind| kind as i32) != Some(target.kind) + || target.id.as_ref() != Some(owner), + "architectural self target forbidden", + )?; + crate::decision_document::validate_catalog_title(value.label.as_ref()) + .map_err(|e| ArchitecturalRelationshipError(e.to_string()))?; + if let Some(doc) = &value.doc { + crate::decision_document::validate_catalog_text(Some(doc)) + .map_err(|e| ArchitecturalRelationshipError(e.to_string()))?; + } + } + connections::validate_connection_ids(entity) + .map_err(|e| ArchitecturalRelationshipError(e.to_string())) +} + +fn exact(id: Option<&pb::Id>) -> Result<()> { + require( + id.is_some_and(|id| { + id.version > 0 + && crate::is_safe_id_component(&id.namespace) + && crate::is_safe_id_component(&id.slug) + }), + "invalid exact architectural endpoint", + ) +} + +pub fn resolve_connection_context( + owner: &Entity, + reference: &pb::ConnectionRef, +) -> Result { + require( + matches!( + entity_kind(owner), + Some(EntityKind::Component | EntityKind::ExternalSystem) + ), + "unsupported architectural owner", + )?; + let occurrence = connections::resolve_connection(owner, reference) + .map_err(|e| ArchitecturalRelationshipError(e.to_string()))?; + let prefix = match entity_kind(owner) { + Some(EntityKind::Component) => "component.relationships[", + _ => "external_system.relationships[", + }; + require( + occurrence.path.starts_with(prefix), + "unsupported architectural connection role", + )?; + let target = occurrence + .target + .ok_or_else(|| ArchitecturalRelationshipError("architectural target required".into()))?; + exact(target.id.as_ref())?; + Ok(target) +} + +pub(crate) fn outbound(values: &[pb::ArchitecturalRelationship], out: &mut Vec) { + for (index, value) in values.iter().enumerate() { + if let Some(target) = value.target.as_ref().and_then(endpoint_ref) { + if let (Ok(kind), Some(id)) = (EntityKind::try_from(target.kind), target.id) { + let arm = if kind == EntityKind::Component { + "component" + } else { + "external_system" + }; + out.push(OutboundRef { + to_kind: Some(kind), + to_id: id, + from_field: format!("relationships[{index}].target.{arm}"), + }); + } + } + } +} + +pub(crate) fn retarget( + values: &mut [pb::ArchitecturalRelationship], + kind: EntityKind, + from: &pb::Id, + to: &pb::Id, + rewritten: &mut Vec, +) { + use pb::architectural_endpoint::Target; + for (index, value) in values.iter_mut().enumerate() { + let Some(target) = value + .target + .as_mut() + .and_then(|target| target.target.as_mut()) + else { + continue; + }; + let (target_kind, id, arm) = match target { + Target::Component(value) => (EntityKind::Component, &mut value.id, "component"), + Target::ExternalSystem(value) => { + (EntityKind::ExternalSystem, &mut value.id, "external_system") + } + }; + if kind == target_kind && id.as_ref() == Some(from) && from != to { + *id = Some(to.clone()); + value.connection_id = None; + rewritten.push(format!("relationships[{index}].target.{arm}")); + } + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/connections.rs b/rsworkspace/crates/trogon-atlas-core/src/connections.rs index e3c055ae..b1d9bcf8 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/connections.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/connections.rs @@ -222,7 +222,22 @@ fn visit( let occurrence = ConnectionOccurrence { id: read_id(message)?, path: path.to_owned(), - target: None, + target: if descriptor.full_name() + == "trogonatlas.eventmodel.v1alpha1.ArchitecturalRelationship" + { + message + .get_field_by_name("target") + .as_deref() + .and_then(Value::as_message) + .map(Message::encode_to_vec) + .and_then(|bytes| { + trogon_atlas_proto::ArchitecturalEndpoint::decode(bytes.as_slice()).ok() + }) + .as_ref() + .and_then(crate::architectural_relationships::endpoint_ref) + } else { + None + }, signature, }; callback(message, occurrence)?; diff --git a/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs b/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs index 03723715..98124a4d 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs @@ -53,7 +53,7 @@ fn text(value: &str, limit: usize) -> Result<()> { "catalog text blank or byte limit exceeded", ) } -fn title(value: Option<&pb::CatalogTitle>) -> Result<()> { +pub fn validate_catalog_title(value: Option<&pb::CatalogTitle>) -> Result<()> { text( &value .ok_or_else(|| CatalogError("catalog title required".into()))? @@ -61,7 +61,7 @@ fn title(value: Option<&pb::CatalogTitle>) -> Result<()> { 256, ) } -fn prose(value: Option<&pb::CatalogText>) -> Result<()> { +pub fn validate_catalog_text(value: Option<&pb::CatalogText>) -> Result<()> { text( &value .ok_or_else(|| CatalogError("catalog text required".into()))? @@ -247,7 +247,7 @@ fn validate_subjects(entity: &Entity) -> Result<()> { "duplicate full catalog subject", )?; if value.doc.is_some() { - prose(value.doc.as_ref())?; + validate_catalog_text(value.doc.as_ref())?; } } Ok(()) @@ -279,15 +279,15 @@ fn validate_selection_list<'a>( "duplicate full document selection", )?; if doc.is_some() { - prose(doc)?; + validate_catalog_text(doc)?; } } Ok(()) } fn validate_decision(value: &pb::Decision) -> Result<()> { - title(value.title.as_ref())?; - prose(value.context.as_ref())?; - prose(value.rationale.as_ref())?; + validate_catalog_title(value.title.as_ref())?; + validate_catalog_text(value.context.as_ref())?; + validate_catalog_text(value.rationale.as_ref())?; let status = pb::DecisionStatus::try_from(value.status).map_err(failure)?; require( status != pb::DecisionStatus::Unspecified, @@ -310,8 +310,8 @@ fn validate_decision(value: &pb::Decision) -> Result<()> { "option collection outside bounds", )?; for option in &value.options { - title(option.title.as_ref())?; - prose(option.description.as_ref())?; + validate_catalog_title(option.title.as_ref())?; + validate_catalog_text(option.description.as_ref())?; } if let Some(selected) = &value.selected_option { selected.value.parse::()?; @@ -338,7 +338,7 @@ fn validate_decision(value: &pb::Decision) -> Result<()> { ), "unknown consequence kind", )?; - prose(consequence.text.as_ref())?; + validate_catalog_text(consequence.text.as_ref())?; require( seen.insert(consequence.encode_to_vec()), "duplicate consequence", @@ -368,7 +368,7 @@ fn validate_decision(value: &pb::Decision) -> Result<()> { "self or duplicate related decision", )?; if related.doc.is_some() { - prose(related.doc.as_ref())?; + validate_catalog_text(related.doc.as_ref())?; } } validate_selection_list( @@ -389,7 +389,7 @@ fn validate_decision(value: &pb::Decision) -> Result<()> { ) } fn validate_document(value: &pb::Document) -> Result<()> { - title(value.title.as_ref())?; + validate_catalog_title(value.title.as_ref())?; match &value.body { Some(pb::document::Body::Retained(body)) => { require( @@ -434,7 +434,7 @@ fn validate_document(value: &pb::Document) -> Result<()> { )?; let mut seen = HashSet::new(); for anchor in &value.anchors { - title(anchor.label.as_ref())?; + validate_catalog_title(anchor.label.as_ref())?; let fragment = &anchor .fragment .as_ref() @@ -460,7 +460,7 @@ fn validate_document(value: &pb::Document) -> Result<()> { )?; for nav in &value.navigation { if nav.label.is_some() { - title(nav.label.as_ref())?; + validate_catalog_title(nav.label.as_ref())?; } } require( diff --git a/rsworkspace/crates/trogon-atlas-core/src/lib.rs b/rsworkspace/crates/trogon-atlas-core/src/lib.rs index 0be1ca47..2c6b9582 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/lib.rs @@ -13,6 +13,7 @@ //! `trogon_atlas_store::refs` so existing imports keep working with a //! single `use` swap. +pub mod architectural_relationships; pub mod connection_persistence; pub mod connections; pub mod content_hash; diff --git a/rsworkspace/crates/trogon-atlas-core/src/refs.rs b/rsworkspace/crates/trogon-atlas-core/src/refs.rs index f16c3bbc..58b85502 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/refs.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/refs.rs @@ -181,7 +181,10 @@ pub fn outbound_refs(entity: &Entity) -> Vec { }); } } - EntityOneof::Persona(_) | EntityOneof::ExternalSystem(_) | EntityOneof::Screen(_) => {} + EntityOneof::Persona(_) | EntityOneof::Screen(_) => {} + EntityOneof::ExternalSystem(x) => { + crate::architectural_relationships::outbound(&x.relationships, &mut out); + } EntityOneof::Domain(x) => { if let Some(id) = x.project.as_ref().and_then(|p| p.id.as_ref()) { out.push(OutboundRef { @@ -567,6 +570,7 @@ pub fn outbound_refs(entity: &Entity) -> Vec { } } EntityOneof::Component(x) => { + crate::architectural_relationships::outbound(&x.relationships, &mut out); for (i, m) in x.members.iter().enumerate() { if let Some(id) = &m.id { match EntityKind::try_from(m.kind) { @@ -1316,7 +1320,14 @@ pub fn retarget_refs( } } } - EntityOneof::Persona(_) | EntityOneof::ExternalSystem(_) | EntityOneof::Screen(_) => {} + EntityOneof::Persona(_) | EntityOneof::Screen(_) => {} + EntityOneof::ExternalSystem(x) => crate::architectural_relationships::retarget( + &mut x.relationships, + from_kind, + from_id, + to_id, + &mut rewritten, + ), EntityOneof::Domain(x) => { if from_kind == EntityKind::Project && x.project.as_ref().and_then(|p| p.id.as_ref()) == Some(from_id) @@ -1780,6 +1791,13 @@ pub fn retarget_refs( } } EntityOneof::Component(x) => { + crate::architectural_relationships::retarget( + &mut x.relationships, + from_kind, + from_id, + to_id, + &mut rewritten, + ); for (i, m) in x.members.iter_mut().enumerate() { if m.id.as_ref() == Some(from_id) { match EntityKind::try_from(m.kind) { diff --git a/rsworkspace/crates/trogon-atlas-core/src/responsibility.rs b/rsworkspace/crates/trogon-atlas-core/src/responsibility.rs index fa8a89cf..383584d6 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/responsibility.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/responsibility.rs @@ -142,6 +142,8 @@ pub fn connection_subject_owner(kind: EntityKind) -> bool { | EntityKind::DataStore | EntityKind::Specification | EntityKind::DataProduct + | EntityKind::Component + | EntityKind::ExternalSystem ) } @@ -262,9 +264,20 @@ pub fn resolve_subject(subject: &pb::ResponsibilitySubject, owner: &Entity) -> R )?; match subject.selection.as_ref() { Some(Selection::Entity(_)) => Ok(()), - Some(Selection::Connection(reference)) => connections::resolve_connection(owner, reference) - .map(|_| ()) - .map_err(|e| ResponsibilityError(e.to_string())), + Some(Selection::Connection(reference)) => { + if matches!( + entity_kind(owner), + Some(EntityKind::Component | EntityKind::ExternalSystem) + ) { + crate::architectural_relationships::resolve_connection_context(owner, reference) + .map(|_| ()) + .map_err(|e| ResponsibilityError(e.to_string())) + } else { + connections::resolve_connection(owner, reference) + .map(|_| ()) + .map_err(|e| ResponsibilityError(e.to_string())) + } + } Some(Selection::Member(reference)) => specification::resolve_member(owner, reference) .map(|_| ()) .map_err(|e| ResponsibilityError(e.to_string())), diff --git a/rsworkspace/crates/trogon-atlas-core/src/source_integration.rs b/rsworkspace/crates/trogon-atlas-core/src/source_integration.rs index 988763fc..7187a7e7 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/source_integration.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/source_integration.rs @@ -70,6 +70,24 @@ fn unit_fields( unit: OwnershipUnit, ) -> Result, SourceError> { let descriptor = value.descriptor(); + if matches!( + descriptor.full_name(), + "trogonatlas.eventmodel.v1alpha1.Component" + | "trogonatlas.eventmodel.v1alpha1.ExternalSystem" + ) { + if unit == OwnershipUnit::Field(SourceOwnedField::Relationships) { + return Err(SourceError::UnsupportedUnit); + } + if unit == OwnershipUnit::WholeOwner + && value + .get_field_by_name("relationships") + .as_deref() + .and_then(Value::as_list) + .is_some_and(|values| !values.is_empty()) + { + return Err(SourceError::UnsupportedUnit); + } + } match unit { OwnershipUnit::WholeOwner => Ok(descriptor.fields().filter(|f| f.name() != "id").collect()), OwnershipUnit::Field(field) => { @@ -138,6 +156,17 @@ pub fn validate_selection_ownership( selection: &SourceSelection, ownership: &SourceFieldOwnership, ) -> Result<(), SourceError> { + if let Some(source_selection::Selection::Connection(reference)) = selection.selection.as_ref() { + if reference.owner.as_ref().is_some_and(|owner| { + matches!( + trogon_atlas_proto::EntityKind::try_from(owner.kind), + Ok(trogon_atlas_proto::EntityKind::Component + | trogon_atlas_proto::EntityKind::ExternalSystem) + ) + }) { + return Err(SourceError::UnsupportedUnit); + } + } match selection .selection .as_ref() diff --git a/rsworkspace/crates/trogon-atlas-core/tests/architectural_relationships.rs b/rsworkspace/crates/trogon-atlas-core/tests/architectural_relationships.rs new file mode 100644 index 00000000..f9f261ee --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/architectural_relationships.rs @@ -0,0 +1,487 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use prost::Message; +use trogon_atlas_core::{ + architectural_relationships::{self as architecture, validate_values}, + connection_persistence::{prepare_entity, prepare_landing, prepare_resolution}, + connections::{self, connection_occurrences, ConnectionError, ConnectionId}, + decision_document, entity_id, entity_kind, outbound_refs, responsibility, retarget_refs, + source_integration::{self, SourceError}, +}; +use trogon_atlas_proto::{self as pb, *}; + +fn id(slug: &str) -> Id { + Id { + namespace: "commerce".into(), + slug: slug.into(), + version: 1, + } +} +fn endpoint(external: bool, slug: &str) -> ArchitecturalEndpoint { + ArchitecturalEndpoint { + target: Some(if external { + architectural_endpoint::Target::ExternalSystem(ExternalSystemRef { id: Some(id(slug)) }) + } else { + architectural_endpoint::Target::Component(ComponentRef { id: Some(id(slug)) }) + }), + } +} +fn relation(external: bool, slug: &str) -> ArchitecturalRelationship { + ArchitecturalRelationship { + target: Some(endpoint(external, slug)), + label: Some(CatalogTitle { + value: "submits orders".into(), + }), + doc: Some(CatalogText { + value: "architectural intent".into(), + }), + ..Default::default() + } +} +fn owner(external: bool) -> Entity { + let relationships = vec![ + relation(true, "provider"), + relation(true, "provider"), + relation(false, "worker"), + ]; + Entity { + kind: Some(if external { + entity::Kind::ExternalSystem(ExternalSystem { + id: Some(id("checkout")), + relationships, + ..Default::default() + }) + } else { + entity::Kind::Component(Component { + id: Some(id("checkout")), + relationships, + members: vec![EntityRef { + kind: EntityKind::Component as i32, + id: Some(id("worker")), + }], + ..Default::default() + }) + }), + ..Default::default() + } +} +fn values(owner: &mut Entity) -> &mut Vec { + match owner.kind.as_mut().unwrap() { + entity::Kind::Component(value) => &mut value.relationships, + entity::Kind::ExternalSystem(value) => &mut value.relationships, + _ => panic!("architectural owner"), + } +} +fn reference(owner: &Entity, index: usize) -> ConnectionRef { + ConnectionRef { + owner: Some(EntityRef { + kind: entity_kind(owner).unwrap() as i32, + id: entity_id(owner).cloned(), + }), + owner_uid: owner.system.as_ref().unwrap().uid.clone(), + id: architecture::relationships(owner)[index] + .connection_id + .clone(), + } +} + +#[test] +fn typed_endpoints_preserve_parallel_occurrences_and_plain_members() { + for external in [false, true] { + let stored = prepare_entity(None, &owner(external)).unwrap(); + validate_values(&stored).unwrap(); + assert_eq!( + Entity::decode(stored.encode_to_vec().as_slice()).unwrap(), + stored + ); + let occurrences = connection_occurrences(&stored).unwrap(); + assert_eq!(occurrences.len(), 3); + assert_ne!(occurrences[0].id, occurrences[1].id); + assert_eq!(occurrences[0].target, occurrences[1].target); + let refs = outbound_refs(&stored); + let declarations: Vec<_> = refs + .iter() + .filter(|r| r.from_field.starts_with("relationships[")) + .collect(); + assert_eq!(declarations.len(), 3); + assert_eq!( + declarations[0].from_field, + "relationships[0].target.external_system" + ); + assert_eq!(declarations[2].to_kind, Some(EntityKind::Component)); + assert_eq!(declarations[2].to_id, id("worker")); + for (index, occurrence) in occurrences.iter().enumerate() { + let selected = reference(&stored, index); + let target = architecture::resolve_connection_context(&stored, &selected).unwrap(); + assert_eq!(Some(target), occurrence.target); + responsibility::resolve_subject( + &ResponsibilitySubject { + selection: Some(responsibility_subject::Selection::Connection( + selected.clone(), + )), + }, + &stored, + ) + .unwrap(); + decision_document::resolve_subject( + &CatalogSubject { + selection: Some(catalog_subject::Selection::Connection(selected)), + }, + &stored, + ) + .unwrap(); + } + let mut member_selection = reference(&stored, 0); + member_selection.id = Some(ConnectionId::new().into()); + assert!(architecture::resolve_connection_context(&stored, &member_selection).is_err()); + } +} + +#[test] +fn wording_and_order_preserve_identity_but_exact_targets_do_not() { + let stored = prepare_entity(None, &owner(false)).unwrap(); + let mut edited = stored.clone(); + values(&mut edited).reverse(); + values(&mut edited)[2].label.as_mut().unwrap().value = "places requests".into(); + values(&mut edited)[2].doc = None; + let edited = prepare_entity(Some(&stored), &edited).unwrap(); + assert_eq!(reference(&stored, 0), reference(&edited, 2)); + assert!(connections::resolve_connection(&edited, &reference(&stored, 0)).is_ok()); + let mut changed = edited.clone(); + values(&mut changed)[0].target = Some(endpoint(true, "worker")); + assert!(matches!( + prepare_entity(Some(&edited), &changed), + Err(ConnectionError::Retargeted(_)) + )); + values(&mut changed)[0].connection_id = None; + let changed = prepare_entity(Some(&edited), &changed).unwrap(); + assert_ne!(reference(&changed, 0).id, reference(&edited, 0).id); +} + +#[test] +fn exact_content_reapply_retains_ids_but_edited_stripping_and_preview_ids_reject() { + let authored = owner(false); + let stored = prepare_entity(None, &authored).unwrap(); + assert_eq!(prepare_entity(Some(&stored), &authored).unwrap(), stored); + let mut edited = authored.clone(); + values(&mut edited)[0].label.as_mut().unwrap().value = "changed".into(); + assert!(matches!( + prepare_entity(Some(&stored), &edited), + Err(ConnectionError::StrippedId(_)) + )); + let mut added = stored.clone(); + values(&mut added).push(relation(false, "worker")); + let preview = prepare_entity(Some(&stored), &added).unwrap(); + assert!(matches!( + prepare_entity(Some(&stored), &preview), + Err(ConnectionError::UnknownId(_)) + )); + let applied = prepare_entity(Some(&stored), &added).unwrap(); + assert_ne!(reference(&preview, 3).id, reference(&applied, 3).id); +} + +#[test] +fn local_removal_restoration_and_recreation_keep_selectors_exact() { + let stored = prepare_entity(None, &owner(false)).unwrap(); + let selected = reference(&stored, 0); + let sibling = reference(&stored, 1); + let mut removed = stored.clone(); + values(&mut removed).remove(0); + let removed = prepare_entity(Some(&stored), &removed).unwrap(); + assert!(connections::resolve_connection(&removed, &selected).is_err()); + assert!(connections::resolve_connection(&removed, &sibling).is_ok()); + assert!(matches!( + prepare_landing(Some(&stored), Some(&removed), &stored), + Err(ConnectionError::RemovedFromBranchBase(_)) + )); + let restored = prepare_resolution(Some(&stored), Some(&removed), &stored).unwrap(); + assert_ne!(reference(&restored, 0).id, selected.id); + assert_eq!(reference(&restored, 1).id, sibling.id); + assert_eq!(reference(&restored, 0).owner_uid, selected.owner_uid); + let recreated = prepare_entity(None, &stored).unwrap(); + assert_ne!(reference(&recreated, 0).owner_uid, selected.owner_uid); + assert!(connections::resolve_connection(&recreated, &selected).is_err()); + let mut readded = removed.clone(); + values(&mut readded).push(relation(true, "provider")); + let readded = prepare_entity(Some(&removed), &readded).unwrap(); + assert_ne!(reference(&readded, 2).id, selected.id); +} + +#[test] +fn retarget_renews_only_matching_occurrences_and_never_moves_subject_selectors() { + let stored = prepare_entity(None, &owner(false)).unwrap(); + let selected = reference(&stored, 0); + let mut changed = stored.clone(); + assert_eq!( + retarget_refs( + &mut changed, + EntityKind::ExternalSystem, + &id("provider"), + &id("replacement") + ), + vec![ + "relationships[0].target.external_system", + "relationships[1].target.external_system" + ] + ); + assert!(values(&mut changed)[0].connection_id.is_none()); + let changed = prepare_entity(Some(&stored), &changed).unwrap(); + assert!(connections::resolve_connection(&changed, &selected).is_err()); + assert_eq!(reference(&stored, 2), reference(&changed, 2)); + let mut team = Entity { + kind: Some(entity::Kind::Team(Team { + responsibilities: vec![Responsibility { + subject: Some(ResponsibilitySubject { + selection: Some(responsibility_subject::Selection::Connection( + selected.clone(), + )), + }), + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }; + let before = team.clone(); + assert_eq!( + retarget_refs( + &mut team, + EntityKind::Component, + &id("checkout"), + &id("replacement") + ), + Vec::::new() + ); + assert_eq!(team, before); +} + +#[test] +fn complete_inventory_is_checked_before_selecting_readable_declaration() { + let stored = prepare_entity(None, &owner(false)).unwrap(); + let selected = reference(&stored, 0); + let mut corrupted = stored.clone(); + values(&mut corrupted)[2].connection_id = None; + assert!(architecture::resolve_connection_context(&corrupted, &selected).is_err()); + values(&mut corrupted)[2].connection_id = selected.id.clone(); + assert!(architecture::resolve_connection_context(&corrupted, &selected).is_err()); + let mut malformed = stored.clone(); + values(&mut malformed)[2] + .connection_id + .as_mut() + .unwrap() + .value = "not-a-uuid".into(); + assert!(architecture::resolve_connection_context(&malformed, &selected).is_err()); + // Resolution uses retained identity and does not require target hydration. + assert_eq!( + architecture::resolve_connection_context(&stored, &selected) + .unwrap() + .id, + Some(id("provider")) + ); +} + +#[test] +fn exact_endpoint_and_rich_text_bounds_reject_without_truncation() { + for external in [false, true] { + let valid = owner(external); + validate_values(&valid).unwrap(); + let mut bad = valid.clone(); + values(&mut bad)[0].target = None; + assert!(validate_values(&bad).is_err()); + values(&mut bad)[0].target = Some(ArchitecturalEndpoint::default()); + assert!(validate_values(&bad).is_err()); + values(&mut bad)[0].target = Some(endpoint(external, "checkout")); + assert!(validate_values(&bad).is_err()); + let target = values(&mut bad)[0].target.as_mut().unwrap(); + match target.target.as_mut().unwrap() { + architectural_endpoint::Target::Component(v) => v.id.as_mut().unwrap().version = 2, + architectural_endpoint::Target::ExternalSystem(v) => v.id.as_mut().unwrap().version = 2, + } + validate_values(&bad).unwrap(); + for invalid in [ + None, + Some(Id { + version: 0, + ..id("target") + }), + Some(Id { + namespace: "../bad".into(), + ..id("target") + }), + ] { + values(&mut bad)[0].target = Some(ArchitecturalEndpoint { + target: Some(architectural_endpoint::Target::Component(ComponentRef { + id: invalid, + })), + }); + assert!(validate_values(&bad).is_err()); + } + for text in [String::new(), " \n".into(), "é".repeat(129)] { + let mut bad = valid.clone(); + values(&mut bad)[0].label = Some(CatalogTitle { value: text }); + assert!(validate_values(&bad).is_err()); + } + let mut bad = valid.clone(); + values(&mut bad)[0].label = None; + assert!(validate_values(&bad).is_err()); + for text in [String::new(), " \n".into(), "é".repeat(8193)] { + let mut bad = valid.clone(); + values(&mut bad)[0].doc = Some(CatalogText { value: text }); + assert!(validate_values(&bad).is_err()); + } + let mut limit = valid; + values(&mut limit)[0].label = Some(CatalogTitle { + value: "é".repeat(128), + }); + values(&mut limit)[0].doc = Some(CatalogText { + value: "é".repeat(8192), + }); + validate_values(&limit).unwrap(); + } + let mut limit = owner(false); + *values(&mut limit) = vec![relation(true, "provider"); architecture::MAX_RELATIONSHIPS]; + validate_values(&limit).unwrap(); + values(&mut limit).push(relation(true, "provider")); + assert!(validate_values(&limit).is_err()); + assert_eq!( + values(&mut limit).len(), + architecture::MAX_RELATIONSHIPS + 1 + ); + *values(&mut limit) = vec![relation(true, "provider"); 100]; + for value in values(&mut limit) { + value.doc = Some(CatalogText { + value: "d".repeat(16_384), + }); + } + assert!(limit.encoded_len() > architecture::MAX_ENCODED_BYTES); + assert!(validate_values(&limit).is_err()); +} + +#[test] +fn supplied_identity_is_validated_and_source_architecture_is_native_only() { + for external in [false, true] { + let mut authored = owner(external); + let supplied: pb::ConnectionId = ConnectionId::new().into(); + values(&mut authored)[0].connection_id = Some(supplied.clone()); + let stored = prepare_entity(None, &authored).unwrap(); + assert_eq!(reference(&stored, 0).id, Some(supplied.clone())); + values(&mut authored)[1].connection_id = Some(supplied); + assert!(validate_values(&authored).is_err()); + assert!(prepare_entity(None, &authored).is_err()); + let selection = SourceSelection { + selection: Some(source_selection::Selection::Connection(reference( + &stored, 0, + ))), + }; + let none = SourceFieldOwnership { + policy: Some(source_field_ownership::Policy::None(SourceNoOwnership {})), + }; + assert_eq!( + source_integration::validate_selection_ownership(&selection, &none), + Err(SourceError::UnsupportedUnit) + ); + let fields = SourceFieldOwnership { + policy: Some(source_field_ownership::Policy::Fields(SourceOwnedFields { + fields: vec![SourceOwnedField::Relationships as i32], + })), + }; + assert_eq!( + source_integration::validate_ownership(&stored, &fields), + Err(SourceError::UnsupportedUnit) + ); + let whole = SourceFieldOwnership { + policy: Some(source_field_ownership::Policy::WholeOwner( + SourceWholeOwnerOwnership {}, + )), + }; + assert_eq!( + source_integration::validate_ownership(&stored, &whole), + Err(SourceError::UnsupportedUnit) + ); + let title = SourceFieldOwnership { + policy: Some(source_field_ownership::Policy::Fields(SourceOwnedFields { + fields: vec![SourceOwnedField::Title as i32], + })), + }; + let unit = source_integration::validate_ownership(&stored, &title).unwrap()[0]; + let selected = source_integration::extract_unit(&stored, unit).unwrap(); + let mut merged = stored.clone(); + source_integration::replace_unit(&mut merged, unit, &selected).unwrap(); + assert_eq!( + architecture::relationships(&merged), + architecture::relationships(&stored) + ); + } +} + +#[test] +fn architectural_subject_resolvers_reject_non_exact_selected_endpoints() { + let invalid_ids = [ + None, + Some(Id { + version: 0, + ..id("target") + }), + Some(Id { + namespace: "../unsafe".into(), + ..id("target") + }), + Some(Id { + slug: "../unsafe".into(), + ..id("target") + }), + ]; + for external_owner in [false, true] { + let stored = prepare_entity(None, &owner(external_owner)).unwrap(); + for external_target in [false, true] { + for invalid_id in &invalid_ids { + let mut malformed = stored.clone(); + values(&mut malformed)[0].target = Some(ArchitecturalEndpoint { + target: Some(if external_target { + architectural_endpoint::Target::ExternalSystem(ExternalSystemRef { + id: invalid_id.clone(), + }) + } else { + architectural_endpoint::Target::Component(ComponentRef { + id: invalid_id.clone(), + }) + }), + }); + let selected = reference(&malformed, 0); + assert!(architecture::resolve_connection_context(&malformed, &selected).is_err(), "owner external={external_owner}, target external={external_target}, id={invalid_id:?}"); + assert!(responsibility::resolve_subject( + &ResponsibilitySubject { + selection: Some(responsibility_subject::Selection::Connection( + selected.clone() + )), + }, + &malformed + ) + .is_err()); + assert!(decision_document::resolve_subject( + &CatalogSubject { + selection: Some(catalog_subject::Selection::Connection(selected)), + }, + &malformed + ) + .is_err()); + } + } + } +} + +#[test] +fn architectural_values_reject_explicit_empty_connection_id_but_allow_omission() { + for external_owner in [false, true] { + let mut authored = owner(external_owner); + assert!(values(&mut authored)[0].connection_id.is_none()); + validate_values(&authored).unwrap(); + values(&mut authored)[0].connection_id = Some(pb::ConnectionId { + value: String::new(), + }); + assert!( + validate_values(&authored).is_err(), + "explicit empty connection ID is malformed on external={external_owner}" + ); + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs b/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs index 5c69caaa..cd84f725 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs @@ -134,7 +134,7 @@ fn new_kind_roundtrips_and_revision_floors_are_coherent() { canonical::parse_kind("DATA_PRODUCT"), Some(EntityKind::DataProduct) ); - assert_eq!(CONTRACT_REVISION, 15); + assert_eq!(CONTRACT_REVISION, 16); assert_eq!(MIN_CLIENT_CONTRACT_REVISION, CONTRACT_REVISION); assert_eq!(MIN_SERVER_CONTRACT_REVISION, CONTRACT_REVISION); let value = stored(); diff --git a/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs b/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs index 7ac6985f..d77db9df 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs @@ -130,7 +130,7 @@ fn wire_allocations_revision_and_presence_are_native() { MIN_CLIENT_CONTRACT_REVISION, MIN_SERVER_CONTRACT_REVISION ), - (15, 15, 15) + (16, 16, 16) ); assert_eq!(canonical::parse_kind("team"), Some(EntityKind::Team)); assert_eq!(canonical::parse_kind("person"), Some(EntityKind::Person)); diff --git a/rsworkspace/crates/trogon-atlas-core/tests/source_integration.rs b/rsworkspace/crates/trogon-atlas-core/tests/source_integration.rs index 986c178e..888480ee 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/source_integration.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/source_integration.rs @@ -313,11 +313,18 @@ fn closed_ownership_enum_covers_every_native_writable_unit() { dynamic.set_field(&variant, prost_reflect::Value::Message(native)); use prost::Message; let entity = Entity::decode(dynamic.encode_to_vec().as_slice()).expect("entity"); - assert!( - validate_ownership(&entity, &ownership(&[field])).is_ok(), - "unit {enum_name} on {}", - owner.full_name() - ); + let result = validate_ownership(&entity, &ownership(&[field])); + if name == "relationships" + && matches!( + owner.full_name(), + "trogonatlas.eventmodel.v1alpha1.Component" + | "trogonatlas.eventmodel.v1alpha1.ExternalSystem" + ) + { + assert_eq!(result, Err(SourceError::UnsupportedUnit)); + } else { + assert!(result.is_ok(), "unit {enum_name} on {}", owner.full_name()); + } } } } diff --git a/rsworkspace/crates/trogon-atlas-core/tests/specification.rs b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs index f4be6c44..d691bbf2 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/specification.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs @@ -165,7 +165,7 @@ fn generated_contract_revision_and_canonical_kind_are_complete() { MIN_SERVER_CONTRACT_REVISION, MIN_CLIENT_CONTRACT_REVISION ), - (15, 15, 15) + (16, 16, 16) ); assert_eq!( canonical::parse_kind("specification"), diff --git a/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs b/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs index 78fd3b7f..f607c4d7 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/tests/mcp_integration.rs @@ -1947,3 +1947,98 @@ fn responsibility_query_budget_error_preserves_structured_public_classification( assert_eq!(data["next_action"], "fix_input"); assert!(!error.message.contains("private backend detail")); } + +#[tokio::test(flavor = "multi_thread")] +async fn architectural_relationship_mcp_native_authoring_queries_and_export_preserve_occurrences() { + use serde_json::json; + let (client, service, _) = start_mcp_client().await; + let ns = "mcp-architecture"; + let id = |slug: &str, version: &str| json!({"namespace":ns,"slug":slug,"version":version}); + call_ok( + &client, + "put_entity_json", + put_entity_json_args( + &json!({"externalSystem":{"id":id("provider","9007199254740993"),"title":"Provider"}}) + .to_string(), + ), + ) + .await; + let relationship = json!({"target":{"externalSystem":{"id":id("provider","9007199254740993")}},"label":{"value":"submits orders"},"doc":{"value":"Architectural intent"}}); + call_ok(&client, "put_entity_json", put_entity_json_args(&json!({"component":{"id":id("checkout","1"),"title":"Checkout","relationships":[relationship.clone(),relationship]}}).to_string())).await; + let args = args_obj(&[ + ("kind", json!("component")), + ("namespace", json!(ns)), + ("slug", json!("checkout")), + ("version", json!(1)), + ]); + let before = call_tool_result_text(&call_ok(&client, "get_entity_json", args.clone()).await); + let body = &before["json"]; + let relationships = body["component"]["relationships"].as_array().unwrap(); + assert_eq!(relationships.len(), 2); + assert_ne!( + relationships[0]["connectionId"], + relationships[1]["connectionId"] + ); + assert_eq!( + relationships[0]["target"]["externalSystem"]["id"]["version"], + "9007199254740993" + ); + let subject = |index: usize| json!({"connection":{"owner":{"kind":"ENTITY_KIND_COMPONENT","id":id("checkout","1")},"ownerUid":body["system"]["uid"],"id":relationships[index]["connectionId"]}}); + call_ok(&client, "put_entity_json", put_entity_json_args(&json!({"team":{"id":id("team","1"),"title":{"value":"Operators"},"responsibilities":[{"kind":"RESPONSIBILITY_KIND_CONTACT","subject":subject(0)}]}}).to_string())).await; + for index in 0..2 { + let result = call_tool_result_text( + &call_ok( + &client, + "list_subject_assignments", + args_obj(&[("request_json", json!({"subject":subject(index)}))]), + ) + .await, + ); + assert_eq!( + result["assignments"].as_array().map_or(0, Vec::len), + usize::from(index == 0), + "{result}" + ); + assert!(result["scope"].is_object()); + } + let exported = call_tool_result_text( + &call_ok( + &client, + "export_namespace", + args_obj(&[("namespace", json!(ns))]), + ) + .await, + ); + let yaml = exported["files"] + .as_object() + .unwrap() + .values() + .map(|value| value.as_str().unwrap()) + .collect::>() + .join("\n---\n"); + call_ok( + &client, + "apply_manifests", + args_obj(&[("manifests_yaml", json!(yaml))]), + ) + .await; + assert_eq!( + before, + call_tool_result_text(&call_ok(&client, "get_entity_json", args).await) + ); + let error = client + .call_tool( + rmcp::model::CallToolRequestParams::new("list_subject_assignments").with_arguments( + args_obj(&[( + "request_json", + json!({"subject":{"connection":{"ownerUid":false}}}), + )]), + ), + ) + .await + .unwrap_err(); + assert!( + matches!(error, rmcp::ServiceError::McpError(error) if error.code == ErrorCode::INVALID_PARAMS) + ); + service.cancel().await.unwrap(); +} 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 a2f52984..ea2e9cf6 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 @@ -3719,6 +3719,316 @@ pub mod subdomain { } } } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogTitle { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogText { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LocalValueId { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExternalDocumentLink { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AnchorFragment { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogSubject { + #[prost(oneof="catalog_subject::Selection", tags="1, 2, 3, 4")] + pub selection: ::core::option::Option, +} +/// Nested message and enum types in `CatalogSubject`. +pub mod catalog_subject { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Selection { + #[prost(message, tag="1")] + Entity(super::EntityRef), + #[prost(message, tag="2")] + Connection(super::ConnectionRef), + #[prost(message, tag="3")] + Member(super::ContractMemberRef), + #[prost(message, tag="4")] + Port(super::DataPortRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SubjectAssociation { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub subject: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionOption { + #[prost(message, optional, tag="1")] + pub option_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub description: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionConsequence { + #[prost(enumeration="decision_consequence::Kind", tag="1")] + pub kind: i32, + #[prost(message, optional, tag="2")] + pub text: ::core::option::Option, +} +/// Nested message and enum types in `DecisionConsequence`. +pub mod decision_consequence { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Kind { + Unspecified = 0, + Benefit = 1, + Cost = 2, + Risk = 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::Benefit => "KIND_BENEFIT", + Self::Cost => "KIND_COST", + Self::Risk => "KIND_RISK", + } + } + /// 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_BENEFIT" => Some(Self::Benefit), + "KIND_COST" => Some(Self::Cost), + "KIND_RISK" => Some(Self::Risk), + _ => None, + } + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionReplacement { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub decision: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelatedDecision { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub decision: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentAnchorRef { + #[prost(message, optional, tag="1")] + pub document: ::core::option::Option, + #[prost(string, tag="2")] + pub owner_uid: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub anchor_id: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub expected_digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentSelection { + #[prost(oneof="document_selection::Selection", tags="1, 2")] + pub selection: ::core::option::Option, +} +/// Nested message and enum types in `DocumentSelection`. +pub mod document_selection { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Selection { + #[prost(message, tag="1")] + Document(super::DocumentRef), + #[prost(message, tag="2")] + Anchor(super::DocumentAnchorRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SupportingDocument { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub document: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Decision { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[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 subjects: ::prost::alloc::vec::Vec, + #[prost(enumeration="DecisionStatus", tag="7")] + pub status: i32, + #[prost(message, optional, tag="8")] + pub context: ::core::option::Option, + #[prost(message, optional, tag="9")] + pub rationale: ::core::option::Option, + #[prost(message, repeated, tag="10")] + pub options: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="11")] + pub selected_option: ::core::option::Option, + #[prost(message, repeated, tag="12")] + pub consequences: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="13")] + pub replaced_by: ::core::option::Option, + #[prost(message, repeated, tag="14")] + pub related_decisions: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="15")] + pub documents: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RetainedDocumentText { + #[prost(message, optional, tag="1")] + pub media_type: ::core::option::Option, + #[prost(string, tag="2")] + pub content: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentAnchor { + #[prost(message, optional, tag="1")] + pub anchor_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub fragment: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub label: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentNavigation { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub document: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub label: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Document { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[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 subjects: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="9")] + pub anchors: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="10")] + pub navigation: ::prost::alloc::vec::Vec, + #[prost(oneof="document::Body", tags="7, 8")] + pub body: ::core::option::Option, +} +/// Nested message and enum types in `Document`. +pub mod document { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Body { + #[prost(message, tag="7")] + Retained(super::RetainedDocumentText), + #[prost(message, tag="8")] + Link(super::ExternalDocumentLink), + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum DecisionStatus { + Unspecified = 0, + Proposed = 1, + Accepted = 2, + Rejected = 3, + Withdrawn = 4, + Superseded = 5, +} +impl DecisionStatus { + /// 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 => "DECISION_STATUS_UNSPECIFIED", + Self::Proposed => "DECISION_STATUS_PROPOSED", + Self::Accepted => "DECISION_STATUS_ACCEPTED", + Self::Rejected => "DECISION_STATUS_REJECTED", + Self::Withdrawn => "DECISION_STATUS_WITHDRAWN", + Self::Superseded => "DECISION_STATUS_SUPERSEDED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "DECISION_STATUS_UNSPECIFIED" => Some(Self::Unspecified), + "DECISION_STATUS_PROPOSED" => Some(Self::Proposed), + "DECISION_STATUS_ACCEPTED" => Some(Self::Accepted), + "DECISION_STATUS_REJECTED" => Some(Self::Rejected), + "DECISION_STATUS_WITHDRAWN" => Some(Self::Withdrawn), + "DECISION_STATUS_SUPERSEDED" => Some(Self::Superseded), + _ => None, + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArchitecturalEndpoint { + #[prost(oneof="architectural_endpoint::Target", tags="1, 2")] + pub target: ::core::option::Option, +} +/// Nested message and enum types in `ArchitecturalEndpoint`. +pub mod architectural_endpoint { + #[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 ArchitecturalRelationship { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub target: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub label: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub doc: ::core::option::Option, +} /// A Component is a living implementation unit: the deployable thing that /// hosts processors, serves UIs, projects read models, exposes APIs, and owns /// its technology choices -- including the messaging it relies on (Kafka @@ -3745,6 +4055,8 @@ pub struct Component { pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, #[prost(message, optional, tag="7")] pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="8")] + pub relationships: ::prost::alloc::vec::Vec, } /// An ExternalSystem is a living third-party boundary: the APIs and webhooks /// it exposes, the tech used to integrate with it. ReadModels with an @@ -3766,6 +4078,8 @@ pub struct ExternalSystem { pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, #[prost(message, optional, tag="6")] pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="7")] + pub relationships: ::prost::alloc::vec::Vec, } /// A Tracker is a project-management overlay: a denormalized list of /// pointers into the model, each carrying delivery status. It integrates @@ -3856,335 +4170,103 @@ pub struct DirectoryRoleText { pub value: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EmailAddress { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ContactUrl { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DirectoryProvider { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct OpaqueDirectoryKey { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ChatContact { - #[prost(message, optional, tag="1")] - pub service: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub handle: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalDirectoryPointer { - #[prost(message, optional, tag="1")] - pub provider: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub key: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ContactPoint { - #[prost(string, optional, tag="4")] - pub label: ::core::option::Option<::prost::alloc::string::String>, - #[prost(oneof="contact_point::Channel", tags="1, 2, 3")] - pub channel: ::core::option::Option, -} -/// Nested message and enum types in `ContactPoint`. -pub mod contact_point { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Channel { - #[prost(message, tag="1")] - Email(super::EmailAddress), - #[prost(message, tag="2")] - Web(super::ContactUrl), - #[prost(message, tag="3")] - Chat(super::ChatContact), - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ResponsibilitySubject { - #[prost(oneof="responsibility_subject::Selection", tags="1, 2, 3, 4")] - pub selection: ::core::option::Option, -} -/// Nested message and enum types in `ResponsibilitySubject`. -pub mod responsibility_subject { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Selection { - #[prost(message, tag="1")] - Entity(super::EntityRef), - #[prost(message, tag="2")] - Connection(super::ConnectionRef), - #[prost(message, tag="3")] - Member(super::ContractMemberRef), - #[prost(message, tag="4")] - Port(super::DataPortRef), - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Responsibility { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(enumeration="ResponsibilityKind", tag="2")] - pub kind: i32, - #[prost(message, optional, tag="3")] - pub subject: ::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 TeamMember { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub person: ::core::option::Option, - #[prost(string, optional, tag="3")] - pub doc: ::core::option::Option<::prost::alloc::string::String>, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Team { - #[prost(message, optional, tag="1")] - pub id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, - #[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 contacts: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="7")] - pub responsibilities: ::prost::alloc::vec::Vec, - #[prost(string, optional, tag="8")] - pub purpose: ::core::option::Option<::prost::alloc::string::String>, - #[prost(message, repeated, tag="9")] - pub members: ::prost::alloc::vec::Vec, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Person { - #[prost(message, optional, tag="1")] - pub id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, - #[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 contacts: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="7")] - pub responsibilities: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag="8")] - pub role: ::core::option::Option, - #[prost(message, optional, tag="9")] - pub directory_pointer: ::core::option::Option, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ResponsibilityKind { - Unspecified = 0, - Owner = 1, - Maintainer = 2, - Steward = 3, - Contact = 4, -} -impl ResponsibilityKind { - /// 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 => "RESPONSIBILITY_KIND_UNSPECIFIED", - Self::Owner => "RESPONSIBILITY_KIND_OWNER", - Self::Maintainer => "RESPONSIBILITY_KIND_MAINTAINER", - Self::Steward => "RESPONSIBILITY_KIND_STEWARD", - Self::Contact => "RESPONSIBILITY_KIND_CONTACT", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "RESPONSIBILITY_KIND_UNSPECIFIED" => Some(Self::Unspecified), - "RESPONSIBILITY_KIND_OWNER" => Some(Self::Owner), - "RESPONSIBILITY_KIND_MAINTAINER" => Some(Self::Maintainer), - "RESPONSIBILITY_KIND_STEWARD" => Some(Self::Steward), - "RESPONSIBILITY_KIND_CONTACT" => Some(Self::Contact), - _ => None, - } - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CatalogTitle { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CatalogText { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct LocalValueId { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalDocumentLink { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct AnchorFragment { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CatalogSubject { - #[prost(oneof="catalog_subject::Selection", tags="1, 2, 3, 4")] - pub selection: ::core::option::Option, -} -/// Nested message and enum types in `CatalogSubject`. -pub mod catalog_subject { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Selection { - #[prost(message, tag="1")] - Entity(super::EntityRef), - #[prost(message, tag="2")] - Connection(super::ConnectionRef), - #[prost(message, tag="3")] - Member(super::ContractMemberRef), - #[prost(message, tag="4")] - Port(super::DataPortRef), - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SubjectAssociation { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub subject: ::core::option::Option, - #[prost(message, optional, tag="3")] - pub doc: ::core::option::Option, +pub struct EmailAddress { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DecisionOption { - #[prost(message, optional, tag="1")] - pub option_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, - #[prost(message, optional, tag="3")] - pub description: ::core::option::Option, +pub struct ContactUrl { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DecisionConsequence { - #[prost(enumeration="decision_consequence::Kind", tag="1")] - pub kind: i32, - #[prost(message, optional, tag="2")] - pub text: ::core::option::Option, +pub struct DirectoryProvider { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, } -/// Nested message and enum types in `DecisionConsequence`. -pub mod decision_consequence { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] - #[repr(i32)] - pub enum Kind { - Unspecified = 0, - Benefit = 1, - Cost = 2, - Risk = 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::Benefit => "KIND_BENEFIT", - Self::Cost => "KIND_COST", - Self::Risk => "KIND_RISK", - } - } - /// 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_BENEFIT" => Some(Self::Benefit), - "KIND_COST" => Some(Self::Cost), - "KIND_RISK" => Some(Self::Risk), - _ => None, - } - } - } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OpaqueDirectoryKey { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DecisionReplacement { +pub struct ChatContact { #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, + pub service: ::core::option::Option, #[prost(message, optional, tag="2")] - pub decision: ::core::option::Option, + pub handle: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RelatedDecision { +pub struct ExternalDirectoryPointer { #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, + pub provider: ::core::option::Option, #[prost(message, optional, tag="2")] - pub decision: ::core::option::Option, - #[prost(message, optional, tag="3")] - pub doc: ::core::option::Option, + pub key: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DocumentAnchorRef { - #[prost(message, optional, tag="1")] - pub document: ::core::option::Option, - #[prost(string, tag="2")] - pub owner_uid: ::prost::alloc::string::String, - #[prost(message, optional, tag="3")] - pub anchor_id: ::core::option::Option, - #[prost(message, optional, tag="4")] - pub expected_digest: ::core::option::Option, +pub struct ContactPoint { + #[prost(string, optional, tag="4")] + pub label: ::core::option::Option<::prost::alloc::string::String>, + #[prost(oneof="contact_point::Channel", tags="1, 2, 3")] + pub channel: ::core::option::Option, +} +/// Nested message and enum types in `ContactPoint`. +pub mod contact_point { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Channel { + #[prost(message, tag="1")] + Email(super::EmailAddress), + #[prost(message, tag="2")] + Web(super::ContactUrl), + #[prost(message, tag="3")] + Chat(super::ChatContact), + } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DocumentSelection { - #[prost(oneof="document_selection::Selection", tags="1, 2")] - pub selection: ::core::option::Option, +pub struct ResponsibilitySubject { + #[prost(oneof="responsibility_subject::Selection", tags="1, 2, 3, 4")] + pub selection: ::core::option::Option, } -/// Nested message and enum types in `DocumentSelection`. -pub mod document_selection { +/// Nested message and enum types in `ResponsibilitySubject`. +pub mod responsibility_subject { #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] pub enum Selection { #[prost(message, tag="1")] - Document(super::DocumentRef), + Entity(super::EntityRef), #[prost(message, tag="2")] - Anchor(super::DocumentAnchorRef), + Connection(super::ConnectionRef), + #[prost(message, tag="3")] + Member(super::ContractMemberRef), + #[prost(message, tag="4")] + Port(super::DataPortRef), } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SupportingDocument { +pub struct Responsibility { #[prost(message, optional, tag="1")] pub connection_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub document: ::core::option::Option, + #[prost(enumeration="ResponsibilityKind", tag="2")] + pub kind: i32, #[prost(message, optional, tag="3")] - pub doc: ::core::option::Option, + pub subject: ::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 TeamMember { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub person: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct Decision { +pub struct Team { #[prost(message, optional, tag="1")] pub id: ::core::option::Option, #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, + pub title: ::core::option::Option, #[prost(string, optional, tag="3")] pub doc: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, repeated, tag="4")] @@ -4192,59 +4274,20 @@ pub struct Decision { #[prost(message, optional, tag="5")] pub supersedes: ::core::option::Option, #[prost(message, repeated, tag="6")] - pub subjects: ::prost::alloc::vec::Vec, - #[prost(enumeration="DecisionStatus", tag="7")] - pub status: i32, - #[prost(message, optional, tag="8")] - pub context: ::core::option::Option, - #[prost(message, optional, tag="9")] - pub rationale: ::core::option::Option, - #[prost(message, repeated, tag="10")] - pub options: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag="11")] - pub selected_option: ::core::option::Option, - #[prost(message, repeated, tag="12")] - pub consequences: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag="13")] - pub replaced_by: ::core::option::Option, - #[prost(message, repeated, tag="14")] - pub related_decisions: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="15")] - pub documents: ::prost::alloc::vec::Vec, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RetainedDocumentText { - #[prost(message, optional, tag="1")] - pub media_type: ::core::option::Option, - #[prost(string, tag="2")] - pub content: ::prost::alloc::string::String, - #[prost(message, optional, tag="3")] - pub digest: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DocumentAnchor { - #[prost(message, optional, tag="1")] - pub anchor_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub fragment: ::core::option::Option, - #[prost(message, optional, tag="3")] - pub label: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DocumentNavigation { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub document: ::core::option::Option, - #[prost(message, optional, tag="3")] - pub label: ::core::option::Option, + pub contacts: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub responsibilities: ::prost::alloc::vec::Vec, + #[prost(string, optional, tag="8")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="9")] + pub members: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct Document { +pub struct Person { #[prost(message, optional, tag="1")] pub id: ::core::option::Option, #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, + pub title: ::core::option::Option, #[prost(string, optional, tag="3")] pub doc: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, repeated, tag="4")] @@ -4252,58 +4295,45 @@ pub struct Document { #[prost(message, optional, tag="5")] pub supersedes: ::core::option::Option, #[prost(message, repeated, tag="6")] - pub subjects: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="9")] - pub anchors: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="10")] - pub navigation: ::prost::alloc::vec::Vec, - #[prost(oneof="document::Body", tags="7, 8")] - pub body: ::core::option::Option, -} -/// Nested message and enum types in `Document`. -pub mod document { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Body { - #[prost(message, tag="7")] - Retained(super::RetainedDocumentText), - #[prost(message, tag="8")] - Link(super::ExternalDocumentLink), - } + pub contacts: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub responsibilities: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="8")] + pub role: ::core::option::Option, + #[prost(message, optional, tag="9")] + pub directory_pointer: ::core::option::Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] -pub enum DecisionStatus { +pub enum ResponsibilityKind { Unspecified = 0, - Proposed = 1, - Accepted = 2, - Rejected = 3, - Withdrawn = 4, - Superseded = 5, + Owner = 1, + Maintainer = 2, + Steward = 3, + Contact = 4, } -impl DecisionStatus { +impl ResponsibilityKind { /// 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 => "DECISION_STATUS_UNSPECIFIED", - Self::Proposed => "DECISION_STATUS_PROPOSED", - Self::Accepted => "DECISION_STATUS_ACCEPTED", - Self::Rejected => "DECISION_STATUS_REJECTED", - Self::Withdrawn => "DECISION_STATUS_WITHDRAWN", - Self::Superseded => "DECISION_STATUS_SUPERSEDED", + Self::Unspecified => "RESPONSIBILITY_KIND_UNSPECIFIED", + Self::Owner => "RESPONSIBILITY_KIND_OWNER", + Self::Maintainer => "RESPONSIBILITY_KIND_MAINTAINER", + Self::Steward => "RESPONSIBILITY_KIND_STEWARD", + Self::Contact => "RESPONSIBILITY_KIND_CONTACT", } } /// Creates an enum from field names used in the ProtoBuf definition. pub fn from_str_name(value: &str) -> ::core::option::Option { match value { - "DECISION_STATUS_UNSPECIFIED" => Some(Self::Unspecified), - "DECISION_STATUS_PROPOSED" => Some(Self::Proposed), - "DECISION_STATUS_ACCEPTED" => Some(Self::Accepted), - "DECISION_STATUS_REJECTED" => Some(Self::Rejected), - "DECISION_STATUS_WITHDRAWN" => Some(Self::Withdrawn), - "DECISION_STATUS_SUPERSEDED" => Some(Self::Superseded), + "RESPONSIBILITY_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "RESPONSIBILITY_KIND_OWNER" => Some(Self::Owner), + "RESPONSIBILITY_KIND_MAINTAINER" => Some(Self::Maintainer), + "RESPONSIBILITY_KIND_STEWARD" => Some(Self::Steward), + "RESPONSIBILITY_KIND_CONTACT" => Some(Self::Contact), _ => None, } } diff --git a/rsworkspace/crates/trogon-atlas-proto/src/lib.rs b/rsworkspace/crates/trogon-atlas-proto/src/lib.rs index f06ff1e3..4f9a00fc 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 = 15; +pub const CONTRACT_REVISION: u32 = 16; /// Oldest server contract revision clients built from this tree mutate /// against. -pub const MIN_SERVER_CONTRACT_REVISION: u32 = 15; +pub const MIN_SERVER_CONTRACT_REVISION: u32 = 16; /// Oldest client contract revision a server built from this tree accepts /// mutations from. -pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 15; +pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 16; /// Serialized `FileDescriptorSet` for the schema, for reflection-based /// tooling (textproto/JSON transcoding). diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs index 5c412de2..23b2144e 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs @@ -21,6 +21,17 @@ enum ArchitectureTargetAccess<'a> { ExactDiagnostic(&'a Lens), } +fn admission_references(entity: &pb::Entity) -> Vec { + let architectural = matches!( + entity.kind, + Some(pb::entity::Kind::Component(_) | pb::entity::Kind::ExternalSystem(_)) + ); + trogon_atlas_core::refs::outbound_refs(entity) + .into_iter() + .filter(|reference| !architectural || reference.from_field.starts_with("relationships[")) + .collect() +} + fn is_catalog(entity: &pb::Entity) -> bool { matches!( entity.kind, @@ -91,7 +102,9 @@ impl EventModelServiceImpl { meta.is_put && matches!( meta.kind, - pb::EntityKind::Channel + pb::EntityKind::Component + | pb::EntityKind::ExternalSystem + | pb::EntityKind::Channel | pb::EntityKind::DataStore | pb::EntityKind::Specification | pb::EntityKind::DataProduct @@ -129,7 +142,7 @@ impl EventModelServiceImpl { continue; } required.extend( - trogon_atlas_core::refs::outbound_refs(entity) + admission_references(entity) .into_iter() .filter_map(|reference| reference.to_kind.map(|kind| (kind, reference.to_id))), ); @@ -230,6 +243,7 @@ impl EventModelServiceImpl { }; let prepared = prepare_proposal_entity(&proposal, kind, id, previous.as_ref(), resource)?; + super::validate_entity_content(&prepared)?; let issues = validation::architecture_resources::validate(&prepared, &context); if !issues.is_empty() { return Err(Status::invalid_argument(super::format_scenario_issues( @@ -274,7 +288,9 @@ impl EventModelServiceImpl { matches!( entity.kind, Some( - pb::entity::Kind::Channel(_) + pb::entity::Kind::Component(_) + | pb::entity::Kind::ExternalSystem(_) + | pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_) | pb::entity::Kind::Specification(_) | pb::entity::Kind::DataProduct(_) @@ -299,7 +315,7 @@ impl EventModelServiceImpl { .iter() .filter(|entity| !is_catalog(entity)) .flat_map(|entity| { - trogon_atlas_core::refs::outbound_refs(entity) + admission_references(entity) .into_iter() .filter_map(|reference| reference.to_kind.map(|kind| (kind, reference.to_id))) }) @@ -466,6 +482,8 @@ impl EventModelServiceImpl { .list( super::ListFilter { kinds: &[ + pb::EntityKind::Component, + pb::EntityKind::ExternalSystem, pb::EntityKind::Channel, pb::EntityKind::DataStore, pb::EntityKind::Specification, @@ -796,7 +814,9 @@ impl EventModelServiceImpl { if !matches!( entity.kind, Some( - pb::entity::Kind::Channel(_) + pb::entity::Kind::Component(_) + | pb::entity::Kind::ExternalSystem(_) + | pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_) | pb::entity::Kind::Specification(_) | pb::entity::Kind::DataProduct(_) diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs index 42cffbdd..712e2c93 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs @@ -123,7 +123,9 @@ pub(crate) fn is_resource(entity: &pb::Entity) -> bool { matches!( entity.kind, Some( - pb::entity::Kind::Channel(_) + pb::entity::Kind::Component(_) + | pb::entity::Kind::ExternalSystem(_) + | pb::entity::Kind::Channel(_) | pb::entity::Kind::DataStore(_) | pb::entity::Kind::Specification(_) | pb::entity::Kind::DataProduct(_) @@ -335,6 +337,32 @@ pub(super) fn validate_in_context( issues: Vec::new(), }; match entity.kind.as_ref() { + Some(pb::entity::Kind::Component(_) | pb::entity::Kind::ExternalSystem(_)) => { + checks.value( + trogon_atlas_core::architectural_relationships::validate_values(entity).is_ok(), + "relationships", + ); + for (index, relationship) in + trogon_atlas_core::architectural_relationships::relationships(entity) + .iter() + .enumerate() + { + if let Some(target) = relationship + .target + .as_ref() + .and_then(trogon_atlas_core::architectural_relationships::endpoint_ref) + { + if let Ok(kind) = pb::EntityKind::try_from(target.kind) { + checks.target( + kind, + target.id.as_ref(), + context, + &format!("relationships[{index}].target"), + ); + } + } + } + } Some(pb::entity::Kind::Channel(channel)) => { checks.supersedes( pb::EntityKind::Channel, diff --git a/rsworkspace/crates/trogon-atlas-server/src/conv.rs b/rsworkspace/crates/trogon-atlas-server/src/conv.rs index 3dbbfb2f..6ab6407f 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/conv.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/conv.rs @@ -404,6 +404,8 @@ pub fn validate_entity_content(entity: &pb::Entity) -> Result<(), Status> { trogon_atlas_core::connections::validate_connection_ids(entity) .map_err(|error| Status::invalid_argument(error.to_string()))?; + trogon_atlas_core::architectural_relationships::validate_values(entity) + .map_err(|error| Status::invalid_argument(error.to_string()))?; let (title, doc, fields_len, metadata_len) = match entity.kind.as_ref() { Some(K::Event(x)) => ( x.title.as_str(), diff --git a/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs index 91cd3d8f..6d8cf17a 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs @@ -112,6 +112,13 @@ impl CatalogBudget { return Ok(()); } let count = match kind { + 1 if matches!( + owner.kind, + Some(pb::entity::Kind::Component(_) | pb::entity::Kind::ExternalSystem(_)) + ) => + { + 0 + } 1 => trogon_atlas_core::connections::connection_occurrences(owner) .map_err(|_| Status::internal("cannot inspect native connection inventory"))? .len(), diff --git a/rsworkspace/crates/trogon-atlas-server/src/graph.rs b/rsworkspace/crates/trogon-atlas-server/src/graph.rs index f9957d7c..2e29ed7f 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/graph.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/graph.rs @@ -116,7 +116,9 @@ fn responsibility_occurrence_count(all: &[StoredEntity]) -> usize { if !matches!( entity.kind, Some( - pb::entity::Kind::Team(_) + pb::entity::Kind::Component(_) + | pb::entity::Kind::ExternalSystem(_) + | pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_) | pb::entity::Kind::Decision(_) | pb::entity::Kind::Document(_) @@ -961,6 +963,17 @@ fn catalog_provenance( ..Default::default() }; trogon_atlas_core::decision_document::decorate_reference(entity, &mut reference); + if let Some(index) = field + .strip_prefix("relationships[") + .and_then(|rest| rest.split_once(']')) + .and_then(|(index, suffix)| suffix.starts_with(".target.").then_some(index)) + .and_then(|index| index.parse::().ok()) + { + reference.connection_id = + trogon_atlas_core::architectural_relationships::relationships(entity) + .get(index) + .and_then(|relationship| relationship.connection_id.clone()); + } ( reference.catalog_subject, reference.document_selection, diff --git a/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs b/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs index a41b7b99..458b5382 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs @@ -23,6 +23,8 @@ pub(super) struct ResponsibilityQuery { lens: Lens, branch: Option, examined: usize, + examined_owners: HashSet, + resource_rows: HashSet, output: usize, subjects: Vec, } @@ -42,6 +44,22 @@ impl ResponsibilityQuery { Ok(()) } + fn owner(&mut self, entity: &pb::Entity) -> Result<(), Status> { + let key = pb::EntityKey::new(entity_kind(entity)?, entity_id(entity)?); + if crate::validation::architecture_resources::is_resource(entity) + && self.resource_rows.insert(key.clone()) + && self.resource_rows.len() > 5_000 + { + return Err(Status::resource_exhausted( + "responsibility resource budget exceeded", + )); + } + if self.examined_owners.insert(key) { + self.examine(occurrences(entity))?; + } + Ok(()) + } + fn output(&mut self) -> Result<(), Status> { self.output += 1; if self.output > 4_096 { @@ -201,13 +219,17 @@ impl EventModelServiceImpl { incomplete = true; break; } - let Some(row) = self.responsibility_read(&mut query, &reference).await? else { + let Some(row) = self.responsibility_lookup(&mut query, &reference).await? else { incomplete = true; continue; }; - if !query.examine_within_budget(occurrences(&row.entity)) { - incomplete = true; - break; + match query.owner(&row.entity) { + Ok(()) => {} + Err(status) if status.code() == tonic::Code::ResourceExhausted => { + incomplete = true; + break; + } + Err(status) => return Err(status), } for target in trogon_atlas_core::refs::outbound_refs(&row.entity) { if let Some(kind) = target.to_kind { @@ -232,7 +254,7 @@ impl EventModelServiceImpl { id: Some(target.to_id.clone()), }; if self - .responsibility_read(&mut query, &reference) + .responsibility_lookup(&mut query, &reference) .await? .is_some() { @@ -279,12 +301,14 @@ impl EventModelServiceImpl { lens, branch: branch.map(str::to_owned), examined: 0, + examined_owners: HashSet::new(), + resource_rows: HashSet::new(), output: 0, subjects: Vec::new(), }) } - async fn responsibility_read( + async fn responsibility_lookup( &self, query: &mut ResponsibilityQuery, reference: &pb::EntityRef, @@ -295,8 +319,8 @@ impl EventModelServiceImpl { return Err(Status::not_found("target not found")); } let key = pb::EntityKey::new(kind, id); - if let Some(row) = query.world.get(&key) { - return Ok(row.clone()); + if let Some(row) = query.world.get(&key).cloned() { + return Ok(row); } let row = match self.store.get(kind, id, query.branch.as_deref()).await { Ok(row) => Some(row), @@ -307,6 +331,18 @@ impl EventModelServiceImpl { Ok(row) } + async fn responsibility_read( + &self, + query: &mut ResponsibilityQuery, + reference: &pb::EntityRef, + ) -> Result, Status> { + let row = self.responsibility_lookup(query, reference).await?; + if let Some(row) = &row { + query.owner(&row.entity)?; + } + Ok(row) + } + async fn responsibility_state( &self, query: &mut ResponsibilityQuery, @@ -331,18 +367,30 @@ impl EventModelServiceImpl { if trogon_atlas_core::responsibility::resolve_subject(subject, &row.entity).is_err() { return Ok(State::Unresolved); } + let architectural = matches!( + row.entity.kind, + Some(pb::entity::Kind::Component(_) | pb::entity::Kind::ExternalSystem(_)) + ); for (kind, id) in selected_endpoints(subject, &row.entity) { if !query.lens.admits(&id.namespace) { - return Ok(State::Inaccessible); + return Ok(if architectural { + State::Unresolved + } else { + State::Inaccessible + }); + } + let target = self + .responsibility_read( + query, + &pb::EntityRef { + kind: kind as i32, + id: Some(id), + }, + ) + .await?; + if architectural && target.is_none() { + return Ok(State::Unresolved); } - self.responsibility_read( - query, - &pb::EntityRef { - kind: kind as i32, - id: Some(id), - }, - ) - .await?; } Ok(State::Resolved) } @@ -370,7 +418,13 @@ impl EventModelServiceImpl { } let mut found = Vec::new(); for row in query.rows.clone() { - query.examine(occurrences(&row.entity))?; + if !matches!( + row.entity.kind, + Some(pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_)) + ) { + continue; + } + query.owner(&row.entity)?; for assignment in assignments(&row.entity) { if assignment.subject.as_ref() == Some(&subject) && filter.is_none_or(|kind| assignment.kind == kind) @@ -409,7 +463,7 @@ impl EventModelServiceImpl { .responsibility_read(&mut query, &reference) .await? .ok_or_else(|| Status::not_found("assignee not found"))?; - query.examine(occurrences(&row.entity))?; + query.owner(&row.entity)?; let mut found = Vec::new(); for assignment in assignments(&row.entity) { if filter.is_some_and(|kind| assignment.kind != kind) { @@ -485,7 +539,7 @@ impl EventModelServiceImpl { let Some(pb::entity::Kind::Team(value)) = &row.entity.kind else { return Err(Status::internal("invalid stored team")); }; - query.examine(occurrences(&row.entity))?; + query.owner(&row.entity)?; let mut found = Vec::new(); for member in &value.members { query.output()?; @@ -521,8 +575,8 @@ impl EventModelServiceImpl { .ok_or_else(|| Status::not_found("person not found"))?; let mut found = Vec::new(); for row in query.rows.clone() { - query.examine(occurrences(&row.entity))?; if let Some(pb::entity::Kind::Team(team)) = &row.entity.kind { + query.owner(&row.entity)?; for member in &team.members { if member.person.as_ref() == Some(&person) { query.output()?; diff --git a/rsworkspace/crates/trogon-atlas-server/src/responsibility_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/responsibility_resources.rs index 94a76f70..aedc9c9c 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/responsibility_resources.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/responsibility_resources.rs @@ -9,6 +9,8 @@ pub(crate) fn occurrence_count(entity: &pb::Entity) -> usize { .len() .saturating_add(match &entity.kind { Some(pb::entity::Kind::Team(value)) => value.members.len(), + Some(pb::entity::Kind::Component(value)) => value.relationships.len(), + Some(pb::entity::Kind::ExternalSystem(value)) => value.relationships.len(), _ => 0, }) } diff --git a/rsworkspace/crates/trogon-atlas-server/src/source_integration.rs b/rsworkspace/crates/trogon-atlas-server/src/source_integration.rs index 489fcff2..c7eac148 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/source_integration.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/source_integration.rs @@ -873,6 +873,29 @@ fn selection_owner(selection: &pb::SourceSelection) -> Result, + incoming: Option<&pb::Entity>, + ownership: &pb::SourceFieldOwnership, +) -> Result<(), Status> { + let has_relationships = |entity: &pb::Entity| { + !trogon_atlas_core::architectural_relationships::relationships(entity).is_empty() + }; + let whole_owner = matches!( + ownership.policy, + Some(pb::source_field_ownership::Policy::WholeOwner(_)) + ); + if (current.is_none() && incoming.is_some_and(has_relationships)) + || (whole_owner + && (current.is_some_and(has_relationships) || incoming.is_some_and(has_relationships))) + { + return Err(Status::invalid_argument( + "architectural relationships support native authoring only", + )); + } + Ok(()) +} + fn resolve_selection(selection: &pb::SourceSelection, entity: &pb::Entity) -> Result<(), Status> { use pb::source_selection::Selection; match selection.selection.as_ref().ok_or_else(invalid)? { @@ -1052,6 +1075,7 @@ async fn prepare_mapping( } match checked_selection(service, ctx, selection).await { Ok(current) => { + native_relationships_only(Some(¤t.entity), None, policy)?; bounded(core::validate_ownership(¤t.entity, policy))?; let observation = observed(&owner, ¤t); let adoption = value.adoption.as_ref().ok_or_else(|| { @@ -1864,6 +1888,11 @@ async fn preview_source_run_attempt( Err(StoreError::NotFound) => None, Err(e) => return Err(store_err(e)), }; + native_relationships_only( + current.as_ref().map(|value| &value.entity), + Some(incoming), + ownership, + )?; let guard = native_guard(&owner, current.as_ref()); if !plan.native_guards.contains(&guard) { plan.native_guards.push(guard.clone()); @@ -2126,6 +2155,11 @@ async fn preview_source_run_attempt( ) .await .map_err(store_err)?; + native_relationships_only( + Some(¤t.entity), + None, + mapping.ownership.as_ref().ok_or_else(invalid)?, + )?; let guard = native_guard(&owner, Some(¤t)); if !plan.native_guards.contains(&guard) { plan.native_guards.push(guard.clone()); diff --git a/rsworkspace/crates/trogon-atlas-server/tests/responsibility_backend.rs b/rsworkspace/crates/trogon-atlas-server/tests/responsibility_backend.rs index a4bb112a..57a615f1 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/responsibility_backend.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/responsibility_backend.rs @@ -2178,3 +2178,191 @@ async fn assert_generic_retarget_preserves_local_selectors(client: &mut Client) owner ); } + +#[tokio::test(flavor = "multi_thread")] +async fn architectural_relationship_hidden_and_missing_endpoints_share_unavailable_state() { + let mut client = client().await; + let mut namespaces = Vec::new(); + for (name, parent) in [ + ("architectural-visible", "visible"), + ("architectural-private", "hidden"), + ] { + namespaces.push( + client + .register_namespace(request( + pb::RegisterNamespaceRequest { + name: name.into(), + parent: parent.into(), + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id, + ); + } + let target_id = pb::Id { + namespace: namespaces[1].clone(), + slug: "private-provider".into(), + version: 1, + }; + let target = entity(pb::entity::Kind::ExternalSystem(pb::ExternalSystem { + id: Some(target_id.clone()), + title: "Private provider title".into(), + ..Default::default() + })); + put(&mut client, target.clone(), None).await; + let source_id = pb::Id { + namespace: namespaces[0].clone(), + slug: "public-owner".into(), + version: 1, + }; + let source = entity(pb::entity::Kind::Component(pb::Component { + id: Some(source_id.clone()), + title: "Readable owner".into(), + relationships: vec![pb::ArchitecturalRelationship { + target: Some(pb::ArchitecturalEndpoint { + target: Some(pb::architectural_endpoint::Target::ExternalSystem( + pb::ExternalSystemRef { + id: Some(target_id.clone()), + }, + )), + }), + label: Some(pb::CatalogTitle { + value: "calls".into(), + }), + ..Default::default() + }], + ..Default::default() + })); + assert_eq!( + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(source.clone()), + ..Default::default() + }, + READER, + None + )) + .await + .unwrap_err() + .code(), + Code::PermissionDenied + ); + assert_eq!( + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(source.clone()), + ..Default::default() + }, + LIMITED, + None + )) + .await + .unwrap_err() + .code(), + Code::NotFound + ); + let source = put(&mut client, source, None).await.entity.unwrap(); + let occurrence = trogon_atlas_core::architectural_relationships::relationships(&source)[0] + .connection_id + .clone(); + let selection = pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Connection( + pb::ConnectionRef { + owner: Some(reference(&source)), + owner_uid: source.system.as_ref().unwrap().uid.clone(), + id: occurrence, + }, + )), + }; + let person_id = pb::Id { + namespace: namespaces[0].clone(), + slug: "contact".into(), + version: 1, + }; + let contact = entity(pb::entity::Kind::Person(pb::Person { + id: Some(person_id.clone()), + title: Some(pb::DirectoryDisplayName { + value: "Contact".into(), + }), + responsibilities: vec![pb::Responsibility { + kind: pb::ResponsibilityKind::Contact as i32, + subject: Some(selection.clone()), + ..Default::default() + }], + ..Default::default() + })); + put(&mut client, contact, None).await; + let query = pb::ListAssigneeResponsibilitiesRequest { + assignee: Some(pb::ResponsibilityAssignee { + selection: Some(pb::responsibility_assignee::Selection::Person( + pb::PersonRef { + id: Some(person_id), + }, + )), + }), + ..Default::default() + }; + let hidden = client + .list_assignee_responsibilities(request(query.clone(), LIMITED, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(hidden.assignments.len(), 1); + assert_eq!( + hidden.assignments[0].assignment.as_ref().unwrap().subject, + Some(selection) + ); + assert_eq!( + hidden.assignments[0].state, + pb::ResponsibilityResolutionState::Unresolved as i32 + ); + assert!(!format!("{hidden:?}").contains("Private provider title")); + assert_eq!( + client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(target_id.clone()) + }, + LIMITED, + None + )) + .await + .unwrap_err() + .code(), + Code::NotFound + ); + delete(&mut client, reference(&target), true, None) + .await + .unwrap(); + let missing = client + .list_assignee_responsibilities(request(query, LIMITED, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(missing.assignments, hidden.assignments); + assert_eq!( + client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::Component as i32, + id: Some(source_id) + }, + LIMITED, + None + )) + .await + .unwrap() + .into_inner() + .entity, + Some(source) + ); +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/source_integration_backend.rs b/rsworkspace/crates/trogon-atlas-server/tests/source_integration_backend.rs index 96895dc4..75d0a6a9 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/source_integration_backend.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/source_integration_backend.rs @@ -4958,3 +4958,326 @@ async fn source_apply_preserves_concurrent_recovery_settlement() { .unwrap(); assert_eq!(retained, recovered); } + +#[tokio::test] +async fn architectural_source_title_only_create_rejects_without_native_or_mapping_base_publication() +{ + for external in [false, true] { + let nats = trogon_atlas_testsupport::shared().await; + let mut server = server(nats.store().await).await; + let namespace = namespace(&mut server.client, "source-architecture", "visible-owner").await; + let registration = registration( + &mut server.client, + "architecture-source", + &namespace, + None, + None, + ) + .await; + let target_id = pb::Id { + namespace: namespace.clone(), + slug: "target".into(), + version: 1, + }; + server + .client + .put_entity(req( + ADMIN, + pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(pb::entity::Kind::Component(pb::Component { + id: Some(target_id.clone()), + title: "Target".into(), + ..Default::default() + })), + ..Default::default() + }), + ..Default::default() + }, + None, + )) + .await + .unwrap(); + let owner_id = pb::Id { + namespace: namespace.clone(), + slug: "source-owner".into(), + version: 1, + }; + let kind = if external { + pb::EntityKind::ExternalSystem + } else { + pb::EntityKind::Component + }; + let selection = pb::SourceSelection { + selection: Some(pb::source_selection::Selection::Entity(pb::EntityRef { + kind: kind as i32, + id: Some(owner_id.clone()), + })), + }; + let mapping = server + .client + .create_source_mapping(req( + ADMIN, + pb::CreateSourceMappingRequest { + registration_revision: registration.revision.clone(), + mapping: Some(pb::SourceMapping { + id: "architecture-mapping".into(), + source: Some(source(®istration.id)), + selection: Some(selection), + ownership: Some(pb::SourceFieldOwnership { + policy: Some(pb::source_field_ownership::Policy::Fields( + pb::SourceOwnedFields { + fields: vec![pb::SourceOwnedField::Title as i32], + }, + )), + }), + ..Default::default() + }), + }, + None, + )) + .await + .unwrap() + .into_inner() + .mapping + .unwrap(); + assert!(mapping.base.is_none()); + let snapshot = server + .client + .accept_source_snapshot(req( + ADMIN, + pb::AcceptSourceSnapshotRequest { + registration_id: registration.id.clone(), + registration_revision: registration.revision.clone(), + inventory_bytes: inventory(®istration.id, "v1"), + files: vec![pb::SourceFile { + logical_name: "native/model".into(), + media_type: "application/octet-stream".into(), + content: b"native supplied content".to_vec(), + ..Default::default() + }], + }, + None, + )) + .await + .unwrap() + .into_inner() + .snapshot + .unwrap(); + let relationships = vec![pb::ArchitecturalRelationship { + target: Some(pb::ArchitecturalEndpoint { + target: Some(pb::architectural_endpoint::Target::Component( + pb::ComponentRef { + id: Some(target_id), + }, + )), + }), + label: Some(pb::CatalogTitle { + value: "calls".into(), + }), + ..Default::default() + }]; + let incoming = pb::Entity { + kind: Some(if external { + pb::entity::Kind::ExternalSystem(pb::ExternalSystem { + id: Some(owner_id.clone()), + title: "Imported".into(), + relationships, + ..Default::default() + }) + } else { + pb::entity::Kind::Component(pb::Component { + id: Some(owner_id.clone()), + title: "Imported".into(), + relationships, + ..Default::default() + }) + }), + ..Default::default() + }; + let error = server + .client + .preview_source_run(req( + ADMIN, + pb::PreviewSourceRunRequest { + registration_id: registration.id.clone(), + registration_revision: registration.revision.clone(), + snapshot_id: snapshot.id.clone(), + run_id: "architecture-create".into(), + native_payloads: vec![pb::SourceNativePayload { + source: Some(source(®istration.id)), + entity: Some(incoming.clone()), + }], + ..Default::default() + }, + None, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error}"); + assert_eq!( + server + .client + .get_entity(req( + ADMIN, + pb::GetEntityRequest { + kind: kind as i32, + id: Some(owner_id.clone()) + }, + None + )) + .await + .unwrap_err() + .code(), + Code::NotFound + ); + let after = server + .client + .get_source_mapping(req( + ADMIN, + pb::GetSourceMappingRequest { + registration_id: registration.id.clone(), + mapping_id: mapping.id.clone(), + }, + None, + )) + .await + .unwrap() + .into_inner() + .mapping + .unwrap(); + assert_eq!(after, mapping); + let native = server + .client + .put_entity(req( + ADMIN, + pb::PutEntityRequest { + entity: Some(incoming), + ..Default::default() + }, + None, + )) + .await + .unwrap() + .into_inner(); + let native_entity = native.entity.unwrap(); + let mut adoption = mapping.clone(); + adoption.adoption = Some(pb::SourceAdoption { + authorized_adoption: true, + observed: Some(pb::SourceObservedOwner { + owner: Some(pb::EntityRef { + kind: kind as i32, + id: Some(owner_id.clone()), + }), + uid: native_entity.system.as_ref().unwrap().uid.clone(), + etag: native.etag.clone(), + }), + }); + let adopted = server + .client + .update_source_mapping(req( + ADMIN, + pb::UpdateSourceMappingRequest { + registration_revision: registration.revision.clone(), + if_match: mapping.revision, + mapping: Some(adoption), + }, + None, + )) + .await + .unwrap() + .into_inner() + .mapping + .unwrap(); + let mut unrelated = native_entity.clone(); + match unrelated.kind.as_mut().unwrap() { + pb::entity::Kind::Component(value) => { + value.title = "Source title".into(); + value.relationships.clear(); + } + pb::entity::Kind::ExternalSystem(value) => { + value.title = "Source title".into(); + value.relationships.clear(); + } + _ => panic!("architectural owner"), + } + let run = server + .client + .preview_source_run(req( + ADMIN, + pb::PreviewSourceRunRequest { + registration_id: registration.id.clone(), + registration_revision: registration.revision.clone(), + snapshot_id: snapshot.id, + run_id: "architecture-title-update".into(), + native_payloads: vec![pb::SourceNativePayload { + source: Some(source(®istration.id)), + entity: Some(unrelated), + }], + ..Default::default() + }, + None, + )) + .await + .unwrap() + .into_inner() + .run + .unwrap(); + let applied = apply(&mut server.client, &run).await; + assert_eq!(applied.status, pb::SourceRunStatus::Applied as i32); + let persisted = server + .client + .get_entity(req( + ADMIN, + pb::GetEntityRequest { + kind: kind as i32, + id: Some(owner_id), + }, + None, + )) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + assert_eq!( + trogon_atlas_core::architectural_relationships::relationships(&persisted), + trogon_atlas_core::architectural_relationships::relationships(&native_entity) + ); + let current_mapping = server + .client + .get_source_mapping(req( + ADMIN, + pb::GetSourceMappingRequest { + registration_id: registration.id.clone(), + mapping_id: adopted.id.clone(), + }, + None, + )) + .await + .unwrap() + .into_inner() + .mapping + .unwrap(); + let mut whole_owner = current_mapping.clone(); + whole_owner.ownership = Some(pb::SourceFieldOwnership { + policy: Some(pb::source_field_ownership::Policy::WholeOwner( + pb::SourceWholeOwnerOwnership {}, + )), + }); + let error = server + .client + .update_source_mapping(req( + ADMIN, + pb::UpdateSourceMappingRequest { + registration_revision: registration.revision, + if_match: current_mapping.revision, + mapping: Some(whole_owner), + }, + None, + )) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_acceptance.rs b/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_acceptance.rs new file mode 100644 index 00000000..43f7d02a --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_acceptance.rs @@ -0,0 +1,301 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod support; + +use std::sync::Arc; + +use support::two_tenants::{authed, make_registry, serve_with_registry, ROOT_TOKEN}; +use tonic::transport::Channel; +use trogon_atlas_proto::{self as pb, event_model_service_client::EventModelServiceClient}; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +type Client = EventModelServiceClient; + +fn entity(kind: pb::entity::Kind) -> pb::Entity { + pb::Entity { + kind: Some(kind), + ..Default::default() + } +} + +fn reference(value: &pb::Entity) -> pb::EntityRef { + trogon_atlas_server::conv::entity_to_ref(value).unwrap() +} + +fn id(namespace: &str, slug: &str) -> pb::Id { + pb::Id { + namespace: namespace.into(), + slug: slug.into(), + version: 1, + } +} + +async fn put(client: &mut Client, entity: pb::Entity, etag: &str) -> pb::PutEntityResponse { + client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(entity), + if_match: etag.into(), + ..Default::default() + }, + ROOT_TOKEN, + )) + .await + .unwrap() + .into_inner() +} + +fn relationships(value: &pb::Entity) -> &[pb::ArchitecturalRelationship] { + let Some(pb::entity::Kind::Component(component)) = &value.kind else { + panic!("expected component"); + }; + &component.relationships +} + +async fn assert_subject_counts(client: &mut Client, selected: &pb::ConnectionRef, count: usize) { + let documents = client + .list_subject_documents(authed( + pb::ListSubjectDocumentsRequest { + subject: Some(pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Connection(selected.clone())), + }), + }, + ROOT_TOKEN, + )) + .await + .unwrap() + .into_inner(); + assert!(!documents.incomplete); + assert_eq!(documents.declarations.len(), count); + let assignments = client + .list_subject_assignments(authed( + pb::ListSubjectAssignmentsRequest { + subject: Some(pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Connection( + selected.clone(), + )), + }), + kind: None, + }, + ROOT_TOKEN, + )) + .await + .unwrap() + .into_inner(); + assert!(!assignments.incomplete); + assert_eq!(assignments.assignments.len(), count); +} + +#[tokio::test(flavor = "multi_thread")] +async fn parallel_declarations_keep_provenance_and_exact_subjects_after_reorder() { + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let service = EventModelServiceImpl::try_new(store, ServerMetrics::default()).unwrap(); + let mut client = serve_with_registry(service, make_registry()).await; + let namespace = client + .register_namespace(authed( + pb::RegisterNamespaceRequest { + name: "relationship-acceptance".into(), + parent: "commerce".into(), + }, + ROOT_TOKEN, + )) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id; + let target = put( + &mut client, + entity(pb::entity::Kind::ExternalSystem(pb::ExternalSystem { + id: Some(id(&namespace, "provider")), + title: "Provider".into(), + ..Default::default() + })), + "", + ) + .await + .entity + .unwrap(); + let declaration = pb::ArchitecturalRelationship { + target: Some(pb::ArchitecturalEndpoint { + target: Some(pb::architectural_endpoint::Target::ExternalSystem( + pb::ExternalSystemRef { + id: reference(&target).id, + }, + )), + }), + label: Some(pb::CatalogTitle { + value: "submits orders".into(), + }), + ..Default::default() + }; + let authored = entity(pb::entity::Kind::Component(pb::Component { + id: Some(id(&namespace, "checkout")), + title: "Checkout".into(), + members: vec![reference(&target)], + relationships: vec![declaration.clone(), declaration], + ..Default::default() + })); + let mut malformed = authored.clone(); + let Some(pb::entity::Kind::Component(component)) = &mut malformed.kind else { + panic!("expected component"); + }; + component.relationships[0].connection_id = Some(pb::ConnectionId { + value: String::new(), + }); + let rejected = client + .put_entity(authed( + pb::PutEntityRequest { + entity: Some(malformed), + ..Default::default() + }, + ROOT_TOKEN, + )) + .await + .unwrap_err(); + assert_eq!(rejected.code(), tonic::Code::InvalidArgument); + let absent = client + .get_entity(authed( + pb::GetEntityRequest { + kind: pb::EntityKind::Component as i32, + id: Some(id(&namespace, "checkout")), + }, + ROOT_TOKEN, + )) + .await + .unwrap_err(); + assert_eq!(absent.code(), tonic::Code::NotFound); + let created = put(&mut client, authored, "").await; + let mut owner = created.entity.unwrap(); + let ids: Vec<_> = relationships(&owner) + .iter() + .map(|value| value.connection_id.clone().unwrap()) + .collect(); + assert_ne!(ids[0], ids[1]); + let selectors: Vec<_> = ids + .iter() + .map(|id| pb::ConnectionRef { + owner: Some(reference(&owner)), + owner_uid: owner.system.as_ref().unwrap().uid.clone(), + id: Some(id.clone()), + }) + .collect(); + let query = |target: &pb::Entity| pb::GetReferencesRequest { + kind: reference(target).kind, + id: reference(target).id, + ..Default::default() + }; + let outgoing = client + .get_outgoing_references(authed(query(&owner), ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(outgoing.references.len(), 3); + assert_eq!( + outgoing + .references + .iter() + .filter(|hit| hit.connection_id.is_none()) + .count(), + 1 + ); + for (index, connection_id) in ids.iter().enumerate() { + let hit = outgoing + .references + .iter() + .find(|hit| hit.connection_id.as_ref() == Some(connection_id)) + .unwrap(); + assert_eq!(hit.from, Some(reference(&owner))); + assert_eq!(hit.from_uid, selectors[index].owner_uid); + assert_eq!(hit.to, Some(reference(&target))); + assert_eq!( + hit.from_field, + format!("relationships[{index}].target.external_system") + ); + } + let incoming = client + .get_incoming_references(authed(query(&target), ROOT_TOKEN)) + .await + .unwrap() + .into_inner(); + assert_eq!(incoming.references.len(), outgoing.references.len()); + for hit in &outgoing.references { + assert!(incoming.references.contains(hit)); + } + let note = "These are authored declarations."; + put( + &mut client, + entity(pb::entity::Kind::Document(pb::Document { + id: Some(id(&namespace, "rationale")), + title: Some(pb::CatalogTitle { + value: "Rationale".into(), + }), + subjects: vec![pb::SubjectAssociation { + subject: Some(pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Connection( + selectors[0].clone(), + )), + }), + ..Default::default() + }], + body: Some(pb::document::Body::Retained(pb::RetainedDocumentText { + media_type: Some(pb::MediaType { + value: "text/plain".into(), + }), + content: note.into(), + digest: Some(trogon_atlas_core::specification::content_digest(note)), + })), + ..Default::default() + })), + "", + ) + .await; + put( + &mut client, + entity(pb::entity::Kind::Person(pb::Person { + id: Some(id(&namespace, "maintainer")), + title: Some(pb::DirectoryDisplayName { + value: "Maintainer".into(), + }), + responsibilities: vec![pb::Responsibility { + kind: pb::ResponsibilityKind::Maintainer as i32, + subject: Some(pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Connection( + selectors[0].clone(), + )), + }), + ..Default::default() + }], + ..Default::default() + })), + "", + ) + .await; + assert_subject_counts(&mut client, &selectors[0], 1).await; + assert_subject_counts(&mut client, &selectors[1], 0).await; + let Some(pb::entity::Kind::Component(component)) = &mut owner.kind else { + panic!("expected component"); + }; + component.relationships.reverse(); + component.relationships[1].label.as_mut().unwrap().value = "declared order submission".into(); + let edited = put(&mut client, owner, &created.etag).await; + let edited_owner = edited.entity.unwrap(); + assert_eq!( + relationships(&edited_owner)[0].connection_id.as_ref(), + Some(&ids[1]) + ); + assert_eq!( + relationships(&edited_owner)[1].connection_id.as_ref(), + Some(&ids[0]) + ); + assert_eq!( + edited_owner.system.as_ref().unwrap().uid, + selectors[0].owner_uid + ); + assert_subject_counts(&mut client, &selectors[0], 1).await; + assert_subject_counts(&mut client, &selectors[1], 0).await; + let replay = put(&mut client, edited_owner, &edited.etag).await; + assert!(replay.no_op); + assert_eq!(replay.etag, edited.etag); +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_backend.rs b/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_backend.rs new file mode 100644 index 00000000..7b5ba7f0 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/system_relationship_backend.rs @@ -0,0 +1,664 @@ +#![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::architectural_relationships::relationships; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "architecture".into(), + slug: slug.into(), + version: 1, + } +} +fn owner( + slug: &str, + external: bool, + relationships: Vec, +) -> pb::Entity { + pb::Entity { + kind: Some(if external { + pb::entity::Kind::ExternalSystem(pb::ExternalSystem { + id: Some(id(slug)), + title: slug.into(), + relationships, + ..Default::default() + }) + } else { + pb::entity::Kind::Component(pb::Component { + id: Some(id(slug)), + title: slug.into(), + relationships, + ..Default::default() + }) + }), + ..Default::default() + } +} +fn relationship(slug: &str, external: bool) -> pb::ArchitecturalRelationship { + pb::ArchitecturalRelationship { + target: Some(pb::ArchitecturalEndpoint { + target: Some(if external { + pb::architectural_endpoint::Target::ExternalSystem(pb::ExternalSystemRef { + id: Some(id(slug)), + }) + } else { + pb::architectural_endpoint::Target::Component(pb::ComponentRef { + id: Some(id(slug)), + }) + }), + }), + label: Some(pb::CatalogTitle { + value: "calls".into(), + }), + doc: Some(pb::CatalogText { + value: "authored architectural intent".into(), + }), + ..Default::default() + } +} +fn values(entity: &mut pb::Entity) -> &mut Vec { + match entity.kind.as_mut().unwrap() { + pb::entity::Kind::Component(value) => &mut value.relationships, + pb::entity::Kind::ExternalSystem(value) => &mut value.relationships, + _ => panic!("architectural owner"), + } +} +fn request(body: T, branch: Option<&str>) -> Request { + let mut request = Request::new(body); + if let Some(branch) = branch { + request + .metadata_mut() + .insert("x-trogon-atlas-branch", branch.parse().unwrap()); + } + request +} +async fn service() -> EventModelServiceImpl { + EventModelServiceImpl::try_new( + Arc::new(trogon_atlas_testsupport::shared().await.store().await), + ServerMetrics::default(), + ) + .unwrap() + .with_snapshot_cap(0) +} +async fn save( + service: &EventModelServiceImpl, + entity: pb::Entity, + branch: Option<&str>, +) -> pb::PutEntityResponse { + service + .put_entity(request( + pb::PutEntityRequest { + entity: Some(entity), + ..Default::default() + }, + branch, + )) + .await + .unwrap() + .into_inner() +} +async fn get( + service: &EventModelServiceImpl, + slug: &str, + external: bool, + branch: Option<&str>, +) -> pb::GetEntityResponse { + service + .get_entity(request( + pb::GetEntityRequest { + kind: if external { + pb::EntityKind::ExternalSystem + } else { + pb::EntityKind::Component + } as i32, + id: Some(id(slug)), + }, + branch, + )) + .await + .unwrap() + .into_inner() +} +fn selector(entity: &pb::Entity, index: usize) -> pb::ConnectionRef { + pb::ConnectionRef { + owner: Some(trogon_atlas_server::conv::entity_to_ref(entity).unwrap()), + owner_uid: entity.system.as_ref().unwrap().uid.clone(), + id: relationships(entity)[index].connection_id.clone(), + } +} +async fn fixture() -> EventModelServiceImpl { + let service = service().await; + for (slug, external) in [("provider", true), ("auditor", true), ("worker", false)] { + save(&service, owner(slug, external, vec![]), None).await; + } + save( + &service, + owner( + "checkout", + false, + vec![ + relationship("provider", true), + relationship("provider", true), + relationship("worker", false), + ], + ), + None, + ) + .await; + service +} + +#[tokio::test] +async fn relationship_public_lifecycle_preserves_parallel_provenance_and_rejects_atomic_edits() { + let service = fixture().await; + let before = get(&service, "checkout", false, None).await; + let original = before.entity.as_ref().unwrap(); + let selected = selector(original, 0); + assert_ne!( + relationships(original)[0].connection_id, + relationships(original)[1].connection_id + ); + let incoming = service + .get_incoming_references(Request::new(pb::GetReferencesRequest { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("provider")), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!(incoming.references.len(), 2); + for (index, reference) in incoming.references.iter().enumerate() { + assert_eq!(reference.from, selected.owner); + assert_eq!(reference.from_uid, selected.owner_uid); + assert_eq!( + reference.connection_id, + relationships(original)[index].connection_id + ); + assert_eq!( + reference.from_field, + format!("relationships[{index}].target.external_system") + ); + assert_eq!(reference.to.as_ref().unwrap().id, Some(id("provider"))); + } + let mut rejected = Vec::new(); + let mut stripped = original.clone(); + values(&mut stripped)[0].connection_id = None; + values(&mut stripped)[0].label.as_mut().unwrap().value = "changed".into(); + rejected.push(stripped); + let mut retarget = original.clone(); + values(&mut retarget)[0].target = relationship("auditor", true).target; + rejected.push(retarget); + let mut dangling = original.clone(); + values(&mut dangling).push(relationship("missing", true)); + rejected.push(dangling); + let mut self_target = original.clone(); + values(&mut self_target).push(relationship("checkout", false)); + rejected.push(self_target); + let mut oversized = original.clone(); + values(&mut oversized).resize(1025, relationship("provider", true)); + rejected.push(oversized); + let mut blank = original.clone(); + values(&mut blank)[0].label.as_mut().unwrap().value = " ".into(); + rejected.push(blank); + for entity in rejected { + let error = service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(entity), + if_match: before.etag.clone(), + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{error}"); + assert_eq!(get(&service, "checkout", false, None).await, before); + } + let mut edit = original.clone(); + values(&mut edit).swap(0, 1); + values(&mut edit)[1].label.as_mut().unwrap().value = "submits".into(); + let edited = save(&service, edit, None).await.entity.unwrap(); + assert_eq!(selector(&edited, 1), selected); + let mut removed = edited.clone(); + values(&mut removed).remove(1); + let removed = save(&service, removed, None).await.entity.unwrap(); + assert!(trogon_atlas_core::connections::resolve_connection(&removed, &selected).is_err()); + let mut readded = removed.clone(); + values(&mut readded).push(relationship("provider", true)); + let readded = save(&service, readded, None).await.entity.unwrap(); + assert_ne!(selector(&readded, 2), selected); + assert_eq!(selector(&readded, 0), selector(&edited, 0)); +} + +#[tokio::test] +async fn relationship_public_final_batch_overlay_rejects_tombstones_atomically() { + let service = service().await; + let batch = |entities: Vec| pb::BatchMutateRequest { + ops: entities + .into_iter() + .map(|entity| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(entity), + create_only: true, + ..Default::default() + })), + }) + .collect(), + ..Default::default() + }; + let response = service + .batch_mutate(Request::new(batch(vec![ + owner("checkout", false, vec![relationship("provider", true)]), + owner("provider", true, vec![]), + ]))) + .await + .unwrap() + .into_inner(); + assert_eq!( + response.status, + pb::batch_mutate_response::Status::Applied as i32 + ); + let before = get(&service, "checkout", false, None).await; + let mut edited = before.entity.clone().unwrap(); + values(&mut edited)[0].label.as_mut().unwrap().value = "new wording".into(); + let response = service + .batch_mutate(Request::new(pb::BatchMutateRequest { + ops: vec![ + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(edited), + if_match: before.etag.clone(), + ..Default::default() + })), + }, + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("provider")), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })), + }, + ], + ..Default::default() + })) + .await; + match response { + Err(error) => assert_eq!(error.code(), Code::InvalidArgument), + Ok(response) => assert_ne!( + response.into_inner().status, + pb::batch_mutate_response::Status::Applied as i32 + ), + } + assert_eq!(get(&service, "checkout", false, None).await, before); + get(&service, "provider", true, None).await; +} + +#[tokio::test] +async fn relationship_public_retarget_renews_only_changed_occurrences_and_safe_delete_protects() { + let service = fixture().await; + let before = get(&service, "checkout", false, None).await.entity.unwrap(); + let selected = selector(&before, 0); + let delete = |force| pb::DeleteEntityRequest { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("provider")), + mode: if force { + pb::delete_entity_request::Mode::Force + } else { + pb::delete_entity_request::Mode::FailIfReferenced + } as i32, + ..Default::default() + }; + let protected = service + .delete_entity(Request::new(delete(false))) + .await + .unwrap_err(); + assert_eq!(protected.code(), Code::FailedPrecondition); + get(&service, "provider", true, None).await; + for dry_run in [true, false] { + let response = service + .retarget_references(Request::new(pb::RetargetReferencesRequest { + from: Some(pb::EntityRef { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("provider")), + }), + to: Some(pb::EntityRef { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("auditor")), + }), + filter_kinds: vec![pb::EntityKind::Component as i32], + dry_run, + })) + .await + .unwrap() + .into_inner(); + assert_eq!(response.rewritten, 1); + assert_eq!(response.failed, 0); + if dry_run { + assert_eq!( + get(&service, "checkout", false, None).await.entity, + Some(before.clone()) + ); + } + } + let after = get(&service, "checkout", false, None).await.entity.unwrap(); + assert_ne!(selector(&after, 0), selected); + assert_ne!(selector(&after, 1), selector(&before, 1)); + assert_eq!(selector(&after, 2), selector(&before, 2)); + assert!(trogon_atlas_core::connections::resolve_connection(&after, &selected).is_err()); + service + .delete_entity(Request::new(pb::DeleteEntityRequest { + id: Some(id("auditor")), + ..delete(true) + })) + .await + .unwrap(); + assert_eq!( + get(&service, "checkout", false, None).await.entity, + Some(after) + ); + save(&service, owner("unrelated", false, vec![]), None).await; +} + +#[tokio::test] +async fn relationship_public_branch_edit_and_landing_keep_incarnation_and_ids() { + let service = fixture().await; + let before = get(&service, "checkout", false, None).await; + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "relationship-work".into(), + ..Default::default() + })) + .await + .unwrap(); + let mut edit = before.entity.clone().unwrap(); + values(&mut edit).swap(0, 1); + values(&mut edit)[0].doc = None; + let branch = save(&service, edit, Some("relationship-work")) + .await + .entity + .unwrap(); + assert_eq!(get(&service, "checkout", false, None).await, before); + for dry_run in [true, false] { + let response = service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "relationship-work".into(), + dry_run, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + response.status, + if dry_run { + pb::merge_branch_response::Status::Validated + } else { + pb::merge_branch_response::Status::Applied + } as i32, + "{response:?}" + ); + } + assert_eq!( + get(&service, "checkout", false, None).await.entity, + Some(branch.clone()) + ); + assert_eq!( + selector(&branch, 1), + selector(before.entity.as_ref().unwrap(), 0) + ); +} + +#[tokio::test] +async fn nested_exact_relationship_owners_share_responsibility_occurrence_budget() { + let service = service().await; + save(&service, owner("target", true, vec![]), None).await; + let mut assignments = Vec::new(); + for index in 0..10 { + let entity = save( + &service, + owner( + &format!("owner-{index}"), + false, + vec![relationship("target", true); 1024], + ), + None, + ) + .await + .entity + .unwrap(); + assignments.push(pb::Responsibility { + kind: pb::ResponsibilityKind::Owner as i32, + subject: Some(pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Connection(selector( + &entity, 0, + ))), + }), + ..Default::default() + }); + } + let person = pb::Entity { + kind: Some(pb::entity::Kind::Person(pb::Person { + id: Some(id("maintainer")), + title: Some(pb::DirectoryDisplayName { + value: "Maintainer".into(), + }), + responsibilities: assignments, + ..Default::default() + })), + ..Default::default() + }; + save(&service, person, None).await; + let error = service + .list_assignee_responsibilities(Request::new(pb::ListAssigneeResponsibilitiesRequest { + assignee: Some(pb::ResponsibilityAssignee { + selection: Some(pb::responsibility_assignee::Selection::Person( + pb::PersonRef { + id: Some(id("maintainer")), + }, + )), + }), + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::ResourceExhausted, "{error}"); +} + +async fn unrelated_relationship_scan_fixture() -> EventModelServiceImpl { + let service = service().await; + save(&service, owner("target", true, vec![]), None).await; + for index in 0..10 { + save( + &service, + owner( + &format!("unrelated-{index}"), + index % 2 == 0, + vec![relationship("target", true); 1024], + ), + None, + ) + .await; + } + save( + &service, + pb::Entity { + kind: Some(pb::entity::Kind::Person(pb::Person { + id: Some(id("maintainer")), + title: Some(pb::DirectoryDisplayName { + value: "Maintainer".into(), + }), + ..Default::default() + })), + ..Default::default() + }, + None, + ) + .await; + save( + &service, + pb::Entity { + kind: Some(pb::entity::Kind::Team(pb::Team { + id: Some(id("team")), + title: Some(pb::DirectoryDisplayName { + value: "Team".into(), + }), + members: vec![pb::TeamMember { + person: Some(pb::PersonRef { + id: Some(id("maintainer")), + }), + ..Default::default() + }], + responsibilities: vec![pb::Responsibility { + kind: pb::ResponsibilityKind::Owner as i32, + subject: Some(target_subject()), + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }, + None, + ) + .await; + service +} + +fn target_subject() -> pb::ResponsibilitySubject { + pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Entity( + pb::EntityRef { + kind: pb::EntityKind::ExternalSystem as i32, + id: Some(id("target")), + }, + )), + } +} + +#[tokio::test] +async fn person_teams_does_not_examine_unrelated_relationship_occurrences() { + let service = unrelated_relationship_scan_fixture().await; + let response = service + .list_person_teams(Request::new(pb::ListPersonTeamsRequest { + person: Some(pb::PersonRef { + id: Some(id("maintainer")), + }), + })) + .await + .unwrap() + .into_inner(); + assert!(!response.incomplete); + assert_eq!(response.memberships.len(), 1); + assert_eq!( + response.memberships[0].team.as_ref().unwrap().id, + Some(id("team")) + ); +} + +#[tokio::test] +async fn subject_assignments_does_not_examine_unrelated_relationship_occurrences() { + let service = unrelated_relationship_scan_fixture().await; + let response = service + .list_subject_assignments(Request::new(pb::ListSubjectAssignmentsRequest { + subject: Some(target_subject()), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert!(!response.incomplete); + assert_eq!( + response.subject_state, + pb::ResponsibilityResolutionState::Resolved as i32 + ); + assert_eq!(response.assignments.len(), 1); + assert_eq!( + response.assignments[0].assignment.as_ref().unwrap().subject, + Some(target_subject()) + ); +} + +#[tokio::test] +async fn relationship_public_conflict_resolution_renews_only_restored_ids() { + for take_theirs in [false, true] { + let service = fixture().await; + let original = get(&service, "checkout", false, None).await.entity.unwrap(); + let selected = selector(&original, 0); + let unaffected = selector(&original, 1); + service + .create_branch(Request::new(pb::CreateBranchRequest { + name: "relationship-conflict".into(), + ..Default::default() + })) + .await + .unwrap(); + let mut branch = original.clone(); + values(&mut branch)[1].label.as_mut().unwrap().value = "branch wording".into(); + if take_theirs { + values(&mut branch).remove(0); + } + save(&service, branch, Some("relationship-conflict")).await; + let mut baseline = original.clone(); + values(&mut baseline)[1].label.as_mut().unwrap().value = "baseline wording".into(); + if !take_theirs { + values(&mut baseline).remove(0); + } + save(&service, baseline, None).await; + let before = get(&service, "checkout", false, None).await; + let blocked = service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "relationship-conflict".into(), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + blocked.status, + pb::merge_branch_response::Status::Conflicts as i32 + ); + assert_eq!(get(&service, "checkout", false, None).await, before); + service + .resolve_branch_entry(Request::new(pb::ResolveBranchEntryRequest { + name: "relationship-conflict".into(), + r#ref: selected.owner.clone(), + resolution: if take_theirs { + pb::resolve_branch_entry_request::Resolution::TakeTheirs + } else { + pb::resolve_branch_entry_request::Resolution::KeepOurs + } as i32, + ..Default::default() + })) + .await + .unwrap(); + let resolved = get(&service, "checkout", false, Some("relationship-conflict")) + .await + .entity + .unwrap(); + assert_eq!(relationships(&resolved).len(), 3); + assert_eq!(resolved.system.as_ref().unwrap().uid, selected.owner_uid); + assert!(trogon_atlas_core::connections::resolve_connection(&resolved, &selected).is_err()); + assert!(trogon_atlas_core::connections::resolve_connection(&resolved, &unaffected).is_ok()); + let merged = service + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "relationship-conflict".into(), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + merged.status, + pb::merge_branch_response::Status::Applied as i32, + "{merged:?}" + ); + assert_eq!( + get(&service, "checkout", false, None).await.entity, + Some(resolved) + ); + } +} diff --git a/tsworkspace/webapps/studio/server/index.test.mjs b/tsworkspace/webapps/studio/server/index.test.mjs index 0dd1439a..38440c80 100644 --- a/tsworkspace/webapps/studio/server/index.test.mjs +++ b/tsworkspace/webapps/studio/server/index.test.mjs @@ -2589,6 +2589,10 @@ describe('responsibility strict production serializer boundary', () => { } finally { delete fakeClient[name]; } }); it.each([ + ...['ENTITY_KIND_COMPONENT', 'ENTITY_KIND_EXTERNAL_SYSTEM'].map((kind) => { + const input = { subject: { connection: { owner: { kind, id }, ownerUid: 'opaque-owner-UID', id: { value: 'occurrence-b' } } } }; + return ['subject-assignments', 'ListSubjectAssignments', input, input]; + }), ['subject-assignments', 'ListSubjectAssignments', { subject, kind: 'RESPONSIBILITY_KIND_CONTACT' }, { subject, kind: 'RESPONSIBILITY_KIND_CONTACT' }], ['subject-assignments', 'ListSubjectAssignments', { subject: { entity: { kind: 1, id } }, kind: 4 }, { subject, kind: 'RESPONSIBILITY_KIND_CONTACT' }], ['subject-assignments', 'ListSubjectAssignments', { subject, kind: 0 }, { subject, kind: 'RESPONSIBILITY_KIND_UNSPECIFIED' }], @@ -2639,6 +2643,7 @@ describe('catalog strict gateway serializer boundary', () => { } finally { delete fakeClient[name]; } }); it.each([ + ...['ENTITY_KIND_COMPONENT', 'ENTITY_KIND_EXTERNAL_SYSTEM'].flatMap((kind) => ['subject-decisions', 'subject-documents'].map((route) => [route, route === 'subject-decisions' ? 'ListSubjectDecisions' : 'ListSubjectDocuments', { subject: { connection: { owner: { kind, id }, ownerUid: 'opaque-owner-UID', id: { value: 'occurrence-b' } } } }])), ['subject-decisions', 'ListSubjectDecisions', { subject, status: 'DECISION_STATUS_ACCEPTED' }], ['subject-documents', 'ListSubjectDocuments', { subject }], ['decision-detail', 'GetDecisionDetail', { decision: { id }, followReplacement: false }], diff --git a/tsworkspace/webapps/studio/server/responsibility-contract.test.mjs b/tsworkspace/webapps/studio/server/responsibility-contract.test.mjs index 2250a51f..2c72a8dd 100644 --- a/tsworkspace/webapps/studio/server/responsibility-contract.test.mjs +++ b/tsworkspace/webapps/studio/server/responsibility-contract.test.mjs @@ -99,3 +99,35 @@ describe('strict responsibility requests before production encoding', () => { expect(() => validateResponsibilityRequest(type, input)).toThrow(/expected|unknown/); }); }); + +describe('architectural declaration connection subjects at the read-only gateway', () => { + const root = new protobuf.Root(); + root.resolvePath = (_origin, target) => path.resolve(protoRoot, target); + root.loadSync(path.join(protoRoot, 'trogonatlas/api/eventmodel/v1alpha1/service.proto')); + for (const kind of ['ENTITY_KIND_COMPONENT', 'ENTITY_KIND_EXTERNAL_SYSTEM']) { + const subject = { connection: { owner: { kind, id }, ownerUid: 'opaque-owner-UID', id: { value: 'occurrence-b' } } }; + it(`preserves ${kind} full selector through existing read codecs`, () => { + for (const rpc of ['ListSubjectAssignments', 'ListSubjectDecisions', 'ListSubjectDocuments']) { + const type = root.lookupType(`trogonatlas.api.eventmodel.v1alpha1.${rpc}Request`); + validateResponsibilityRequest(type, { subject }); + expect(roundTrip(`${rpc}Request`, { subject })).toEqual({ subject }); + } + }); + it(`rejects coercion and extra fields before ${kind} encoding`, () => { + const type = root.lookupType('trogonatlas.api.eventmodel.v1alpha1.ListSubjectAssignmentsRequest'); + for (const connection of [ + { ...subject.connection, ownerUid: false }, + { ...subject.connection, id: { value: 3 } }, + { ...subject.connection, path: 'relationships[0]' }, + { ...subject.connection, owner: { kind, id: { ...id, version: Number.MAX_SAFE_INTEGER + 1 } } }, + ]) expect(() => validateResponsibilityRequest(type, { subject: { connection } })).toThrow(/expected|unknown/); + }); + } + it('preserves parallel declarations in real Component and ExternalSystem codecs', () => { + const codec = definitions['trogonatlas.eventmodel.v1alpha1.Entity']; + for (const kind of ['component', 'externalSystem']) { + const entity = { [kind]: { id, title: 'Boundary', relationships: ['a', 'b'].map((value) => ({ connectionId: { value }, target: { externalSystem: { id } }, label: { value: 'submits orders' }, doc: { value: 'Cafe\u0301\r\n' } })) }, system: { uid: 'owner-incarnation' } }; + expect(codec.deserialize(codec.serialize(entity))).toEqual(entity); + } + }); +}); diff --git a/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.test.tsx b/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.test.tsx new file mode 100644 index 00000000..1c0c2ea2 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { buildModel, entityKey } from '@/lib/model'; +import { architecturalDeclarations, resolveArchitecturalSubject } from '@/lib/architectural-relationships'; +import { responsibilitySubjects } from '@/lib/responsibility'; +import { ArchitecturalRelationshipDetails } from './ArchitecturalRelationshipDetails'; + +const id = (slug: string, version = '18446744073709551615') => ({ namespace: 'shop', slug, version }); +const relationship = (value: string) => ({ + connectionId: { value }, + target: { externalSystem: { id: id('provider') } }, + label: { value: 'submits orders' }, + doc: { value: '\nCafe\u0301' }, +}); +function fixture() { + return buildModel([ + { + component: { + id: id('checkout'), + title: 'Checkout', + relationships: ['out-a', 'out-b'].map(relationship), + members: [ + { kind: 'ENTITY_KIND_EXTERNAL_SYSTEM', id: id('provider'), connectionId: { value: 'unsupported-member' } }, + ], + }, + system: { uid: 'checkout-incarnation' }, + }, + { externalSystem: { id: id('provider'), title: 'Provider' }, system: { uid: 'provider-incarnation' } }, + { externalSystem: { id: id('provider', '1'), title: 'Earlier provider' } }, + ]); +} +afterEach(cleanup); +it('preserves parallel declarations, exact endpoint links, inert wording and separate incoming provenance', () => { + const model = fixture(); + const owner = model.entities[0]; + const select = vi.fn(); + const { container, rerender } = render( + , + ); + expect(screen.getAllByText('submits orders')).toHaveLength(2); + expect(screen.getByText('out-a')).toBeDefined(); + expect(screen.getByText('out-b')).toBeDefined(); + expect(screen.getAllByText('checkout-incarnation')).toHaveLength(2); + expect(container.querySelector('script')).toBeNull(); + fireEvent.click(screen.getAllByRole('button', { name: 'shop/provider@18446744073709551615' })[0]); + expect(select).toHaveBeenCalledWith(entityKey('externalSystem', id('provider'))); + expect(model.slices).toHaveLength(0); + rerender(); + expect(screen.getAllByText('checkout-incarnation')).toHaveLength(2); + expect(screen.getByText(/Incoming discovery is limited/)).toBeDefined(); + rerender(); + expect(screen.queryByText('submits orders')).toBeNull(); +}); +it('retains full connection subjects across reorder and refuses stale incarnation or incomplete inventory hydration', () => { + const model = fixture(); + const owner = model.entities[0]; + const subjects = responsibilitySubjects(owner) + .slice(1) + .map((item) => item.subject); + expect(subjects).toHaveLength(2); + const selected = subjects[0]; + expect(selected).toEqual({ + connection: { + owner: { kind: 'ENTITY_KIND_COMPONENT', id: id('checkout') }, + ownerUid: 'checkout-incarnation', + id: { value: 'out-a' }, + }, + }); + (owner.raw.relationships as unknown[]).reverse(); + expect(resolveArchitecturalSubject(model, selected)?.path).toBe('relationships[1]'); + expect( + responsibilitySubjects(owner) + .slice(1) + .map((item) => item.subject), + ).toEqual([...subjects].reverse()); + owner.uid = 'replacement-incarnation'; + expect(resolveArchitecturalSubject(model, selected)).toBeUndefined(); + owner.uid = 'checkout-incarnation'; + owner.raw.relationships = [relationship('out-a'), relationship('out-a')]; + expect(resolveArchitecturalSubject(model, selected)).toBeUndefined(); +}); +it('hydrates authored catalog subjects by exact UID and ID without attaching equal siblings', () => { + const model = fixture(); + const subject = responsibilitySubjects(model.entities[0])[1].subject; + const catalog = buildModel([{ document: { id: id('note'), subjects: [{ subject }] } }]).entities[0]; + render(); + expect(screen.getByText('out-a')).toBeDefined(); + expect(screen.queryByText('out-b')).toBeNull(); + expect(architecturalDeclarations(catalog)).toEqual([]); +}); +it.each(['document', 'decision'])( + 'keeps %s inspection available for a raw connection subject without an ID', + (kind) => { + const model = fixture(); + const subject = responsibilitySubjects(model.entities[0])[1].subject; + const catalog = buildModel([ + { + [kind]: { + id: id('note'), + subjects: [ + { + subject: { + connection: { + owner: { kind: 'ENTITY_KIND_COMPONENT', id: id('checkout') }, + ownerUid: 'checkout-incarnation', + }, + }, + }, + { subject }, + ], + }, + }, + ]).entities[0]; + render(); + expect(screen.getByText('out-a')).toBeDefined(); + expect(screen.queryByText('out-b')).toBeNull(); + }, +); diff --git a/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.tsx b/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.tsx new file mode 100644 index 00000000..238f30a0 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/ArchitecturalRelationshipDetails.tsx @@ -0,0 +1,118 @@ +import { ExactReference } from './ArchitectureResourceDetails'; +import { + architecturalDeclarations, + incomingArchitecturalDeclarations, + resolveArchitecturalSubject, + type ArchitecturalDeclaration, +} from '@/lib/architectural-relationships'; +import type { Entity, Model } from '@/lib/model'; +import { responsibilitySubjects } from '@/lib/responsibility'; + +function Declaration({ + declaration, + model, + onSelect, +}: { + declaration: ArchitecturalDeclaration; + model: Model; + onSelect: (key: string) => void; +}) { + const { owner, target, connectionId, label, doc, path } = declaration; + return ( +
+

{label}

+ {doc !== undefined &&

{doc}

} +
+
Declaration owner
+
+ +
+
Owner UID
+
{owner.uid || 'Unavailable'}
+
Connection ID
+
{connectionId || 'Unassigned'}
+
Diagnostic path
+
{path}
+
Declared target
+
+ {target ? ( + + ) : ( + 'Unavailable typed target' + )} +
+
+
+ ); +} +export function ArchitecturalRelationshipDetails({ + entity, + model, + onSelect, +}: { + entity: Entity; + model: Model; + onSelect: (key: string) => void; +}) { + const boundary = entity.kind === 'component' || entity.kind === 'externalSystem'; + const authored = boundary ? architecturalDeclarations(entity) : []; + const incoming = boundary ? incomingArchitecturalDeclarations(model, entity) : []; + const subjects = + entity.kind === 'decision' || entity.kind === 'document' + ? Array.isArray(entity.raw.subjects) + ? entity.raw.subjects.flatMap((value) => { + const subject = (value as { subject?: Parameters[1] })?.subject; + return subject ? [subject] : []; + }) + : [] + : responsibilitySubjects(entity) + .slice(1) + .map((item) => item.subject); + const selected = boundary + ? [] + : subjects.flatMap((subject) => { + const declaration = resolveArchitecturalSubject(model, subject); + return declaration ? [declaration] : []; + }); + if (!boundary && !selected.length) return null; + const rows = (values: ArchitecturalDeclaration[]) => + values.map((declaration) => ( + + )); + return ( +
+

Architectural declarations

+

+ Directed authored intent. Labels establish no message flow, execution, containment, ownership or human + interaction. +

+ {boundary && ( + <> +

Authored outbound declarations

+ {rows(authored)} + {!authored.length && ( +

No authored outbound declarations in this loaded owner. Operational dependencies remain unknown.

+ )} +

Incoming declarations in the loaded model

+ {rows(incoming)} +

Incoming discovery is limited to loaded owners. Missing records do not establish complete absence.

+ + )} + {!!selected.length && ( + <> +

Selected declarations in the loaded model

+ {rows(selected)} + + )} +

+ Exact owner UID and connection ID identify an occurrence. Loaded wording is current only for this checked view; + target availability and backend subject resolution are separate. +

+
+ ); +} diff --git a/tsworkspace/webapps/studio/src/components/Inspector.tsx b/tsworkspace/webapps/studio/src/components/Inspector.tsx index 9a31ab1e..7399a883 100644 --- a/tsworkspace/webapps/studio/src/components/Inspector.tsx +++ b/tsworkspace/webapps/studio/src/components/Inspector.tsx @@ -4,6 +4,7 @@ import { ExternalLink, FileCode2, Split, TriangleAlert, X } from 'lucide-react'; import { useMemo, useState } from 'react'; import { ResponsibilityInspector } from '@/components/ResponsibilityInspector'; import { DataProductInspector } from '@/components/DataProductInspector'; +import { ArchitecturalRelationshipDetails } from '@/components/ArchitecturalRelationshipDetails'; import { ArchitectureResourceDetails } from '@/components/ArchitectureResourceDetails'; import { SpecificationDetails } from '@/components/SpecificationDetails'; import { CopyContextButton } from '@/components/CopyContextButton'; @@ -639,6 +640,7 @@ export function Inspector({ + {entity.kind === 'dataProduct' && } {entity.kind === 'typeLibrary' ? : null} diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.edge-refresh.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.edge-refresh.test.tsx index a248fd78..06d0c5fd 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.edge-refresh.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.edge-refresh.test.tsx @@ -5,7 +5,8 @@ import { cleanup, render as rtlRender, screen, waitFor } from '@testing-library/ import { withNuqsTestingAdapter } from 'nuqs/adapters/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BoardEdgeData } from '@/lib/layout'; -import { buildModel } from '@/lib/model'; +import { buildModel, type Model } from '@/lib/model'; +import { layoutBoards } from '@/lib/multiboard'; vi.mock('@/lib/api', () => ({ api: { @@ -25,13 +26,22 @@ vi.mock('@/components/canvas/Board', async () => { ...actual, Board: ({ onSelectEdge, + model, }: { + model: Model; onSelectEdge: (edge: { id: string; data: BoardEdgeData }, additive?: boolean) => void; }) => ( @@ -79,6 +89,7 @@ vi.mock('@tanstack/react-hotkeys', () => ({ useHotkey: vi.fn() })); vi.mock('@/components/EdgeInspector', () => ({ EdgeInspector: ({ edge }: { edge: BoardEdgeData }) => (
+ {edge.connectionId?.value} {edge.doc} {JSON.stringify(edge.metadata)} {edge.source.title} @@ -150,4 +161,29 @@ describe('BUG: EdgeInspector must refresh snapshotted edge data after model upda expect(screen.getByTestId('edge-meta').textContent).toContain('fresh'); expect(screen.getByTestId('edge-source').textContent).toBe('Place (fresh)'); }); + it('retains the selected persisted occurrence across equal-endpoint reorder and wording changes', async () => { + const make = (reverse: boolean, uid = 'owner-incarnation') => { + const model = fixture('same wording', [], 'Place'); + const owner = model.entities.find((entity) => entity.kind === 'commandSlice'); + if (!owner) throw new Error('Missing slice fixture'); + owner.uid = uid; + const events = ['occurrence-a', 'occurrence-b'].map((value) => ({ + event: { id: { namespace: 'shop', slug: 'placed', version: '1' } }, + connectionId: { value }, + doc: reverse ? `updated ${value}` : 'same wording', + })); + owner.raw.emittedEvents = reverse ? events : events.reverse(); + return buildModel(model.entities.map((entity) => ({ [entity.kind]: entity.raw, system: { uid: entity.uid } }))); + }; + const { rerender } = rtlRender(, { + wrapper: withNuqsTestingAdapter({ searchParams: '?' }), + }); + fireEvent.click(screen.getByTestId('select-edge')); + await waitFor(() => expect(screen.getByTestId('edge-id').textContent).toBe('occurrence-a')); + rerender(); + await waitFor(() => expect(screen.getByTestId('edge-doc').textContent).toBe('updated occurrence-a')); + expect(screen.getByTestId('edge-id').textContent).toBe('occurrence-a'); + rerender(); + await waitFor(() => expect(screen.queryByTestId('edge-inspector')).toBeNull()); + }); }); diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.tsx index 89a76bc9..c1d262a5 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.tsx @@ -41,6 +41,9 @@ import { isSafeNamespace } from '../../shared/safe-namespace.mjs'; /** Stable identity for an edge across layout rebuilds (occurrence ids shift). */ function edgeIdentity(data: BoardEdgeData): string { + if (data.connectionId?.value && data.slice?.uid) { + return `${data.slice.key}\0${data.slice.uid}\0${data.connectionId.value}`; + } return `${data.source.key}\0${data.relation}\0${data.target.key}`; } diff --git a/tsworkspace/webapps/studio/src/lib/architectural-relationships.ts b/tsworkspace/webapps/studio/src/lib/architectural-relationships.ts new file mode 100644 index 00000000..92e91449 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/architectural-relationships.ts @@ -0,0 +1,58 @@ +import { asId, entityKey, type Entity, type EntityId, type Model, wireKind } from './model'; +import type { ResponsibilitySubject } from './responsibility'; + +export interface ArchitecturalDeclaration { + owner: Entity; + path: string; + connectionId?: string; + target?: { kind: 'component' | 'externalSystem'; id: EntityId }; + label: string; + doc?: string; +} +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} +function text(value: unknown): string | undefined { + const raw = record(value).value; + return typeof raw === 'string' ? raw : undefined; +} +export function architecturalDeclarations(owner: Entity): ArchitecturalDeclaration[] { + if (owner.kind !== 'component' && owner.kind !== 'externalSystem') return []; + if (!Array.isArray(owner.raw.relationships)) return []; + return owner.raw.relationships.map((value, index) => { + const body = record(value); + const endpoint = record(body.target); + const alternatives = (['component', 'externalSystem'] as const).filter((kind) => endpoint[kind]); + const kind = alternatives.length === 1 ? alternatives[0] : undefined; + return { + owner, + path: `relationships[${index}]`, + connectionId: text(body.connectionId), + target: kind ? { kind, id: asId(record(endpoint[kind]).id) } : undefined, + label: text(body.label) ?? 'Undeclared label', + doc: text(body.doc), + }; + }); +} +export function incomingArchitecturalDeclarations(model: Model, target: Entity): ArchitecturalDeclaration[] { + return model.entities + .flatMap(architecturalDeclarations) + .filter( + (declaration) => declaration.target && entityKey(declaration.target.kind, declaration.target.id) === target.key, + ); +} +export function resolveArchitecturalSubject( + model: Model, + subject: ResponsibilitySubject, +): ArchitecturalDeclaration | undefined { + if (!('connection' in subject)) return undefined; + const { owner, ownerUid, id } = subject.connection; + if (!ownerUid || !id?.value) return undefined; + const kind = wireKind(owner.kind); + const entity = kind ? model.byKey.get(entityKey(kind, owner.id)) : undefined; + if (!entity || entity.uid !== ownerUid) return undefined; + const declarations = architecturalDeclarations(entity); + const ids = declarations.map((item) => item.connectionId); + if (ids.some((value) => !value) || new Set(ids).size !== ids.length) return undefined; + return declarations.find((item) => item.connectionId === id.value); +} diff --git a/tsworkspace/webapps/studio/src/lib/responsibility.ts b/tsworkspace/webapps/studio/src/lib/responsibility.ts index 063d5458..485595cb 100644 --- a/tsworkspace/webapps/studio/src/lib/responsibility.ts +++ b/tsworkspace/webapps/studio/src/lib/responsibility.ts @@ -139,6 +139,8 @@ const occurrencePaths: Partial> = { ], swimlane: ['transitions.*.next.*'], ui: ['transitions.*'], + component: ['relationships.*'], + externalSystem: ['relationships.*'], channel: ['bindings.*'], dataStore: ['attachments.*'], specification: [