From 447b43e90cb120b96f8d7f5615cae69f705df79a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 07:45:08 -0400 Subject: [PATCH 1/5] feat: warn when the strategic model is shaped against DDD as it is written Signed-off-by: Yordis Prieto --- .config/mise/tasks/compose/restore-drill | 6 +- .../bounded-context.yaml | 2 +- .../acme-customer-data-deletion/domain.yaml | 13 - .../event-model.yaml | 6 +- .../acme-customer-data-deletion/project.yaml | 8 - .../subdomain.yaml | 14 - .../acme-fulfillment/automation-slice.yaml | 43 + .../acme-fulfillment/bounded-context.yaml | 21 + .../acme-fulfillment/command-slice.yaml | 37 + .../manifests/acme-fulfillment/command.yaml | 32 + .../manifests/acme-fulfillment/component.yaml | 11 + .../acme-fulfillment/event-model.yaml | 99 +++ .../manifests/acme-fulfillment/event.yaml | 28 + .../acme-fulfillment/external-system.yaml | 10 + .../manifests/acme-fulfillment/persona.yaml | 9 + .../manifests/acme-fulfillment/processor.yaml | 14 + .../acme-fulfillment/read-model-slice.yaml | 21 + .../acme-fulfillment/read-model.yaml | 84 ++ .../acme-fulfillment/storyboard.yaml | 46 + .../manifests/acme-fulfillment/swimlane.yaml | 17 + .../manifests/acme-fulfillment/tracker.yaml | 24 + .../ui-slice.yaml | 8 +- examples/manifests/acme-fulfillment/ui.yaml | 8 + .../acme-reviews/bounded-context.yaml | 10 +- examples/manifests/acme-reviews/domain.yaml | 13 - .../manifests/acme-reviews/event-model.yaml | 6 +- examples/manifests/acme-reviews/project.yaml | 8 - .../manifests/acme-reviews/read-model.yaml | 2 +- .../manifests/acme-reviews/storyboard.yaml | 2 +- .../manifests/acme-reviews/subdomain.yaml | 14 - .../acme-storefront/automation-slice.yaml | 44 - .../acme-storefront/bounded-context.yaml | 8 +- .../acme-storefront/command-slice.yaml | 44 - .../manifests/acme-storefront/command.yaml | 33 - .../manifests/acme-storefront/component.yaml | 12 - .../manifests/acme-storefront/domain.yaml | 13 - .../acme-storefront/event-model.yaml | 92 +- examples/manifests/acme-storefront/event.yaml | 29 - .../acme-storefront/external-system.yaml | 11 - .../manifests/acme-storefront/processor.yaml | 15 - .../manifests/acme-storefront/project.yaml | 8 - .../acme-storefront/read-model-slice.yaml | 76 +- .../manifests/acme-storefront/read-model.yaml | 80 +- .../manifests/acme-storefront/storyboard.yaml | 34 +- .../manifests/acme-storefront/subdomain.yaml | 29 - .../manifests/acme-storefront/swimlane.yaml | 18 - .../manifests/acme-storefront/tracker.yaml | 15 - examples/manifests/acme-storefront/ui.yaml | 9 - .../acme-wishlist/bounded-context.yaml | 2 +- examples/manifests/acme-wishlist/domain.yaml | 13 - .../manifests/acme-wishlist/event-model.yaml | 6 +- examples/manifests/acme-wishlist/project.yaml | 8 - .../manifests/acme-wishlist/subdomain.yaml | 14 - examples/manifests/acme/domain.yaml | 13 + examples/manifests/acme/project.yaml | 8 + examples/manifests/acme/subdomain.yaml | 74 ++ .../api/eventmodel/v1alpha1/service.proto | 26 +- rsworkspace/crates/trogon-atlas-cli/README.md | 34 +- .../crates/trogon-atlas-cli/src/main.rs | 243 ++++- .../tests/branch_cli_integration.rs | 66 ++ .../tests/validate_cli_integration.rs | 150 ++++ .../crates/trogon-atlas-client/src/apply.rs | 105 ++- .../crates/trogon-atlas-client/src/branch.rs | 7 +- .../trogon-atlas-client/src/validate.rs | 115 +++ rsworkspace/crates/trogon-atlas-mcp/README.md | 42 + .../crates/trogon-atlas-mcp/src/server.rs | 341 ++++++- .../tests/tool_lifecycle_integration.rs | 202 +++++ .../trogonatlas.api.eventmodel.v1alpha1.rs | 27 +- .../crates/trogon-atlas-server/src/service.rs | 300 ++++++- .../trogon-atlas-server/src/validation.rs | 753 +++++++++++++++- .../tests/account_deletion_fixture.rs | 18 +- .../trogon-atlas-server/tests/branching.rs | 22 +- .../tests/example_manifests_validate.rs | 63 +- .../tests/handler_tests.rs | 132 +++ .../tests/strategic_write_validation.rs | 834 ++++++++++++++++++ 75 files changed, 4028 insertions(+), 776 deletions(-) delete mode 100644 examples/manifests/acme-customer-data-deletion/domain.yaml delete mode 100644 examples/manifests/acme-customer-data-deletion/project.yaml delete mode 100644 examples/manifests/acme-customer-data-deletion/subdomain.yaml create mode 100644 examples/manifests/acme-fulfillment/automation-slice.yaml create mode 100644 examples/manifests/acme-fulfillment/bounded-context.yaml create mode 100644 examples/manifests/acme-fulfillment/command-slice.yaml create mode 100644 examples/manifests/acme-fulfillment/command.yaml create mode 100644 examples/manifests/acme-fulfillment/component.yaml create mode 100644 examples/manifests/acme-fulfillment/event-model.yaml create mode 100644 examples/manifests/acme-fulfillment/event.yaml create mode 100644 examples/manifests/acme-fulfillment/external-system.yaml create mode 100644 examples/manifests/acme-fulfillment/persona.yaml create mode 100644 examples/manifests/acme-fulfillment/processor.yaml create mode 100644 examples/manifests/acme-fulfillment/read-model-slice.yaml create mode 100644 examples/manifests/acme-fulfillment/read-model.yaml create mode 100644 examples/manifests/acme-fulfillment/storyboard.yaml create mode 100644 examples/manifests/acme-fulfillment/swimlane.yaml create mode 100644 examples/manifests/acme-fulfillment/tracker.yaml rename examples/manifests/{acme-storefront => acme-fulfillment}/ui-slice.yaml (73%) create mode 100644 examples/manifests/acme-fulfillment/ui.yaml delete mode 100644 examples/manifests/acme-reviews/domain.yaml delete mode 100644 examples/manifests/acme-reviews/project.yaml delete mode 100644 examples/manifests/acme-reviews/subdomain.yaml delete mode 100644 examples/manifests/acme-storefront/domain.yaml delete mode 100644 examples/manifests/acme-storefront/project.yaml delete mode 100644 examples/manifests/acme-storefront/subdomain.yaml delete mode 100644 examples/manifests/acme-wishlist/domain.yaml delete mode 100644 examples/manifests/acme-wishlist/project.yaml delete mode 100644 examples/manifests/acme-wishlist/subdomain.yaml create mode 100644 examples/manifests/acme/domain.yaml create mode 100644 examples/manifests/acme/project.yaml create mode 100644 examples/manifests/acme/subdomain.yaml create mode 100644 rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs diff --git a/.config/mise/tasks/compose/restore-drill b/.config/mise/tasks/compose/restore-drill index 1a7e1c6b..1df78ee0 100755 --- a/.config/mise/tasks/compose/restore-drill +++ b/.config/mise/tasks/compose/restore-drill @@ -198,7 +198,7 @@ from pathlib import Path d = Path(sys.argv[1]) names = lambda p: sorted(n[0] for n in json.loads((d / f"scope.{p}.json").read_text())["namespaces"]) expect = { - "acme-agent": ["acme-customer-data-deletion", "acme-reviews", "acme-storefront"], + "acme-agent": ["acme", "acme-customer-data-deletion", "acme-fulfillment", "acme-reviews", "acme-storefront"], "globex-agent": ["globex-orders", "ledger"], "partner-reader": ["acme-reviews"], } @@ -249,8 +249,8 @@ touched="$src" quiet dc "$src" up -d --wait --wait-timeout 300 use_stack "$src" -log "seeding acme fixtures as acme-agent (claims acme-* namespaces)" -trogon_atlas_as acme-agent apply -f "$manifests/acme-storefront" "$manifests/acme-reviews" "$manifests/acme-customer-data-deletion" >/dev/null +log "seeding acme fixtures as acme-agent (claims the acme and acme-* namespaces)" +trogon_atlas_as acme-agent apply -f "$manifests/acme" "$manifests/acme-storefront" "$manifests/acme-fulfillment" "$manifests/acme-reviews" "$manifests/acme-customer-data-deletion" >/dev/null log "registering a minted namespace and seeding a second tenant as globex-agent" drill call RegisterNamespace globex-agent '{"name":"ledger"}' >/dev/null diff --git a/examples/manifests/acme-customer-data-deletion/bounded-context.yaml b/examples/manifests/acme-customer-data-deletion/bounded-context.yaml index 07430c3e..7ed5f7b0 100644 --- a/examples/manifests/acme-customer-data-deletion/bounded-context.yaml +++ b/examples/manifests/acme-customer-data-deletion/bounded-context.yaml @@ -7,7 +7,7 @@ spec: doc: Owns the request-to-completion lifecycle of a customer data deletion, and dispatch to every downstream vendor. realizes: - id: - namespace: acme-customer-data-deletion + namespace: acme slug: data-deletion version: '1' title: Acme Customer Data Deletion diff --git a/examples/manifests/acme-customer-data-deletion/domain.yaml b/examples/manifests/acme-customer-data-deletion/domain.yaml deleted file mode 100644 index e1f7e621..00000000 --- a/examples/manifests/acme-customer-data-deletion/domain.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Domain -metadata: - name: customer-privacy - namespace: acme-customer-data-deletion -spec: - doc: Honoring an account owner's request to delete the personal data Acme holds about them. - project: - id: - namespace: acme-customer-data-deletion - slug: acme-customer-data-deletion - version: '1' - title: Customer Privacy diff --git a/examples/manifests/acme-customer-data-deletion/event-model.yaml b/examples/manifests/acme-customer-data-deletion/event-model.yaml index 20896145..ccdd803d 100644 --- a/examples/manifests/acme-customer-data-deletion/event-model.yaml +++ b/examples/manifests/acme-customer-data-deletion/event-model.yaml @@ -7,12 +7,12 @@ spec: doc: Request-to-completion lifecycle of a customer data deletion across every downstream vendor. members: - id: - namespace: acme-customer-data-deletion - slug: customer-privacy + namespace: acme + slug: acme version: '1' kind: ENTITY_KIND_DOMAIN - id: - namespace: acme-customer-data-deletion + namespace: acme slug: data-deletion version: '1' kind: ENTITY_KIND_SUBDOMAIN diff --git a/examples/manifests/acme-customer-data-deletion/project.yaml b/examples/manifests/acme-customer-data-deletion/project.yaml deleted file mode 100644 index ad5f4b36..00000000 --- a/examples/manifests/acme-customer-data-deletion/project.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Project -metadata: - name: acme-customer-data-deletion - namespace: acme-customer-data-deletion -spec: - doc: Customer data deletion across every downstream vendor. - title: Acme Customer Data Deletion diff --git a/examples/manifests/acme-customer-data-deletion/subdomain.yaml b/examples/manifests/acme-customer-data-deletion/subdomain.yaml deleted file mode 100644 index c97d58ec..00000000 --- a/examples/manifests/acme-customer-data-deletion/subdomain.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Subdomain -metadata: - name: data-deletion - namespace: acme-customer-data-deletion -spec: - classification: CLASSIFICATION_SUPPORTING - doc: Coordinating deletion of a customer's personal data across every downstream vendor that holds it. - domain: - id: - namespace: acme-customer-data-deletion - slug: customer-privacy - version: '1' - title: Data Deletion diff --git a/examples/manifests/acme-fulfillment/automation-slice.yaml b/examples/manifests/acme-fulfillment/automation-slice.yaml new file mode 100644 index 00000000..7dbcd58a --- /dev/null +++ b/examples/manifests/acme-fulfillment/automation-slice.yaml @@ -0,0 +1,43 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: AutomationSlice +metadata: + name: mark-fulfilled-auto + namespace: acme-fulfillment +spec: + doc: Calls Acme Shipping Carrier to dispatch the order and records delivery confirmation. + emittedCommand: + command: + id: + namespace: acme-fulfillment + slug: mark-fulfilled + version: '1' + processor: + processor: + id: + namespace: acme-fulfillment + slug: carrier-dispatch-worker + version: '1' + scenarios: + - doc: Queue row. + given: + - readModel: + id: + namespace: acme-fulfillment + slug: fulfillment-queue + version: '1' + id: ok + then: + command: + id: + namespace: acme-fulfillment + slug: mark-fulfilled + version: '1' + title: Delivery confirmed + whenDoc: Acme Shipping Carrier confirms delivery. + sourceReadModels: + - readModel: + id: + namespace: acme-fulfillment + slug: fulfillment-queue + version: '1' + title: Dispatch to carrier diff --git a/examples/manifests/acme-fulfillment/bounded-context.yaml b/examples/manifests/acme-fulfillment/bounded-context.yaml new file mode 100644 index 00000000..a669c872 --- /dev/null +++ b/examples/manifests/acme-fulfillment/bounded-context.yaml @@ -0,0 +1,21 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: BoundedContext +metadata: + name: acme-fulfillment + namespace: acme-fulfillment +spec: + doc: Owns dispatch of a paid order to the shipping carrier, delivery confirmation, and the shopper-facing order tracking. + realizes: + - id: + namespace: acme + slug: fulfillment + version: '1' + relationships: + - doc: Fulfillment needs the order.placed and order.paid facts shaped for dispatch and tracking, so it has a voice when Acme Storefront plans a change to them. + intent: INTENT_CUSTOMER_SUPPLIER + upstream: + id: + namespace: acme-storefront + slug: acme-storefront + version: '1' + title: Acme Fulfillment diff --git a/examples/manifests/acme-fulfillment/command-slice.yaml b/examples/manifests/acme-fulfillment/command-slice.yaml new file mode 100644 index 00000000..849049fd --- /dev/null +++ b/examples/manifests/acme-fulfillment/command-slice.yaml @@ -0,0 +1,37 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: CommandSlice +metadata: + name: mark-fulfilled-slice + namespace: acme-fulfillment +spec: + command: + command: + id: + namespace: acme-fulfillment + slug: mark-fulfilled + version: '1' + doc: Acme Shipping Carrier confirmed delivery. + emittedEvents: + - event: + id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + scenarios: + - doc: Delivery confirmed for a paid order from the fulfillment queue. + emit: + events: + - event: + id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + id: delivered + title: Delivery confirmed + when: + command: + id: + namespace: acme-fulfillment + slug: mark-fulfilled + version: '1' + title: Mark fulfilled diff --git a/examples/manifests/acme-fulfillment/command.yaml b/examples/manifests/acme-fulfillment/command.yaml new file mode 100644 index 00000000..2cd4932c --- /dev/null +++ b/examples/manifests/acme-fulfillment/command.yaml @@ -0,0 +1,32 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Command +metadata: + name: mark-fulfilled + namespace: acme-fulfillment +spec: + doc: Worker-issued. Records that Acme Shipping Carrier confirmed delivery. + schema: + '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema + fields: + - name: order_id + type: + uuid: {} + - name: buyer_id + type: + uuid: {} + - name: seller_id + type: + uuid: {} + - metadata: + - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.DerivedFieldAnnotation + doc: now() when the worker records the outcome + kind: KIND_TIMESTAMP + name: fulfilled_at + type: + timestamp: {} + swimlane: + id: + namespace: acme-fulfillment + slug: fulfillment + version: '1' + title: Mark fulfilled diff --git a/examples/manifests/acme-fulfillment/component.yaml b/examples/manifests/acme-fulfillment/component.yaml new file mode 100644 index 00000000..6211122a --- /dev/null +++ b/examples/manifests/acme-fulfillment/component.yaml @@ -0,0 +1,11 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Component +metadata: + name: carrier-dispatch-module + namespace: acme-fulfillment +spec: + doc: Code module housing the carrier-dispatch-worker's retry and backoff logic. + tech: + - worker + - queue + title: Carrier Dispatch Module diff --git a/examples/manifests/acme-fulfillment/event-model.yaml b/examples/manifests/acme-fulfillment/event-model.yaml new file mode 100644 index 00000000..1859206d --- /dev/null +++ b/examples/manifests/acme-fulfillment/event-model.yaml @@ -0,0 +1,99 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: EventModel +metadata: + name: acme-fulfillment-model + namespace: acme-fulfillment +spec: + doc: 'Carrier fulfillment: dispatching a paid order, confirming delivery, and showing the shopper where the order stands.' + members: + - id: + namespace: acme + slug: acme + version: '1' + kind: ENTITY_KIND_DOMAIN + - id: + namespace: acme + slug: fulfillment + version: '1' + kind: ENTITY_KIND_SUBDOMAIN + - id: + namespace: acme-fulfillment + slug: acme-fulfillment + version: '1' + kind: ENTITY_KIND_BOUNDED_CONTEXT + - id: + namespace: acme-fulfillment + slug: acme-shipping-carrier + version: '1' + kind: ENTITY_KIND_EXTERNAL_SYSTEM + - id: + namespace: acme-fulfillment + slug: carrier-dispatch-module + version: '1' + kind: ENTITY_KIND_COMPONENT + - id: + namespace: acme-fulfillment + slug: shopper + version: '1' + kind: ENTITY_KIND_PERSONA + - id: + namespace: acme-fulfillment + slug: fulfillment + version: '1' + kind: ENTITY_KIND_SWIMLANE + - id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + kind: ENTITY_KIND_EVENT + - id: + namespace: acme-fulfillment + slug: mark-fulfilled + version: '1' + kind: ENTITY_KIND_COMMAND + - id: + namespace: acme-fulfillment + slug: fulfillment-queue + version: '1' + kind: ENTITY_KIND_READ_MODEL + - id: + namespace: acme-fulfillment + slug: order-status + version: '1' + kind: ENTITY_KIND_READ_MODEL + - id: + namespace: acme-fulfillment + slug: carrier-dispatch-worker + version: '1' + kind: ENTITY_KIND_PROCESSOR + - id: + namespace: acme-fulfillment + slug: order-tracking + version: '1' + kind: ENTITY_KIND_UI + - id: + namespace: acme-fulfillment + slug: mark-fulfilled-slice + version: '1' + kind: ENTITY_KIND_COMMAND_SLICE + - id: + namespace: acme-fulfillment + slug: order-status-fulfilled-rms + version: '1' + kind: ENTITY_KIND_READ_MODEL_SLICE + - id: + namespace: acme-fulfillment + slug: mark-fulfilled-auto + version: '1' + kind: ENTITY_KIND_AUTOMATION_SLICE + - id: + namespace: acme-fulfillment + slug: order-tracking-ui-slice + version: '1' + kind: ENTITY_KIND_UI_SLICE + - id: + namespace: acme-fulfillment + slug: fulfillment-flow + version: '1' + kind: ENTITY_KIND_STORYBOARD + title: Acme Fulfillment diff --git a/examples/manifests/acme-fulfillment/event.yaml b/examples/manifests/acme-fulfillment/event.yaml new file mode 100644 index 00000000..493b5ef1 --- /dev/null +++ b/examples/manifests/acme-fulfillment/event.yaml @@ -0,0 +1,28 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Event +metadata: + name: order.fulfilled + namespace: acme-fulfillment +spec: + doc: Acme Shipping Carrier confirmed the order was delivered. + schema: + '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema + fields: + - name: order_id + type: + uuid: {} + - name: buyer_id + type: + uuid: {} + - name: seller_id + type: + uuid: {} + - name: fulfilled_at + type: + timestamp: {} + swimlane: + id: + namespace: acme-fulfillment + slug: fulfillment + version: '1' + title: Order fulfilled diff --git a/examples/manifests/acme-fulfillment/external-system.yaml b/examples/manifests/acme-fulfillment/external-system.yaml new file mode 100644 index 00000000..27b7157a --- /dev/null +++ b/examples/manifests/acme-fulfillment/external-system.yaml @@ -0,0 +1,10 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ExternalSystem +metadata: + name: acme-shipping-carrier + namespace: acme-fulfillment +spec: + doc: Acme Shipping Carrier. Dispatches parcels and confirms delivery over a polled tracking API. + tech: + - carrier-api + title: Acme Shipping Carrier diff --git a/examples/manifests/acme-fulfillment/persona.yaml b/examples/manifests/acme-fulfillment/persona.yaml new file mode 100644 index 00000000..eaf1f7f4 --- /dev/null +++ b/examples/manifests/acme-fulfillment/persona.yaml @@ -0,0 +1,9 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Persona +metadata: + name: shopper + namespace: acme-fulfillment +spec: + doc: The customer following their paid order from dispatch to delivery. + role: customer + title: Shopper diff --git a/examples/manifests/acme-fulfillment/processor.yaml b/examples/manifests/acme-fulfillment/processor.yaml new file mode 100644 index 00000000..677a1176 --- /dev/null +++ b/examples/manifests/acme-fulfillment/processor.yaml @@ -0,0 +1,14 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Processor +metadata: + name: carrier-dispatch-worker + namespace: acme-fulfillment +spec: + calls: + - id: + namespace: acme-fulfillment + slug: acme-shipping-carrier + version: '1' + doc: Dispatches the order to the carrier and records delivery confirmation. + docTrigger: A fulfillment queue row appears. + title: Carrier dispatch worker diff --git a/examples/manifests/acme-fulfillment/read-model-slice.yaml b/examples/manifests/acme-fulfillment/read-model-slice.yaml new file mode 100644 index 00000000..8306220a --- /dev/null +++ b/examples/manifests/acme-fulfillment/read-model-slice.yaml @@ -0,0 +1,21 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ReadModelSlice +metadata: + name: order-status-fulfilled-rms + namespace: acme-fulfillment +spec: + doc: Status row on delivery. + projectionRole: PROJECTION_ROLE_UPSERT + readModel: + readModel: + id: + namespace: acme-fulfillment + slug: order-status + version: '1' + sourceEvents: + - event: + id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + title: 'Projection: order fulfilled' diff --git a/examples/manifests/acme-fulfillment/read-model.yaml b/examples/manifests/acme-fulfillment/read-model.yaml new file mode 100644 index 00000000..40961364 --- /dev/null +++ b/examples/manifests/acme-fulfillment/read-model.yaml @@ -0,0 +1,84 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ReadModel +metadata: + name: fulfillment-queue + namespace: acme-fulfillment +spec: + doc: 'Worker feed: paid orders awaiting carrier dispatch.' + key: + - order_id + schema: + '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema + fields: + - name: order_id + type: + uuid: {} + - name: buyer_id + type: + uuid: {} + - name: seller_id + type: + uuid: {} + - name: paid_at + type: + timestamp: {} + - name: status + type: + string: {} + sourceEvents: + - id: + namespace: acme-storefront + slug: order.paid + version: '1' + title: Fulfillment queue +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: ReadModel +metadata: + name: order-status + namespace: acme-fulfillment +spec: + doc: Current status of an order for the shopper-facing tracking page. + key: + - order_id + schema: + '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema + fields: + - name: order_id + type: + uuid: {} + - name: status + type: + string: {} + - name: cart_id + type: + uuid: {} + - name: buyer_id + type: + uuid: {} + - name: seller_id + type: + uuid: {} + - name: placed_at + type: + timestamp: {} + - name: paid_at + type: + timestamp: {} + - name: fulfilled_at + type: + timestamp: {} + sourceEvents: + - id: + namespace: acme-storefront + slug: order.placed + version: '1' + - id: + namespace: acme-storefront + slug: order.paid + version: '1' + - id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + title: Order status diff --git a/examples/manifests/acme-fulfillment/storyboard.yaml b/examples/manifests/acme-fulfillment/storyboard.yaml new file mode 100644 index 00000000..ded9efbb --- /dev/null +++ b/examples/manifests/acme-fulfillment/storyboard.yaml @@ -0,0 +1,46 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Storyboard +metadata: + name: fulfillment-flow + namespace: acme-fulfillment +spec: + doc: A paid order is dispatched to the carrier and confirmed delivered. It starts from the fulfillment queue that the storefront order.paid event populates, so that projection is upstream preparation and not a step here. + entry: + automation: + processor: + processor: + id: + namespace: acme-fulfillment + slug: carrier-dispatch-worker + version: '1' + readModel: + readModel: + id: + namespace: acme-fulfillment + slug: fulfillment-queue + version: '1' + outcome: + events: + - event: + id: + namespace: acme-fulfillment + slug: order.fulfilled + version: '1' + slices: + - id: + namespace: acme-fulfillment + slug: mark-fulfilled-auto + version: '1' + - id: + namespace: acme-fulfillment + slug: mark-fulfilled-slice + version: '1' + - id: + namespace: acme-fulfillment + slug: order-status-fulfilled-rms + version: '1' + - id: + namespace: acme-fulfillment + slug: order-tracking-ui-slice + version: '1' + title: Fulfillment diff --git a/examples/manifests/acme-fulfillment/swimlane.yaml b/examples/manifests/acme-fulfillment/swimlane.yaml new file mode 100644 index 00000000..55df2b49 --- /dev/null +++ b/examples/manifests/acme-fulfillment/swimlane.yaml @@ -0,0 +1,17 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Swimlane +metadata: + name: fulfillment + namespace: acme-fulfillment +spec: + doc: Dispatch and delivery of a paid order. + order: 1 + state: + - name: status + type: + enumeration: + values: + - pending + - fulfilled + streamId: order-{order_id} + title: Fulfillment diff --git a/examples/manifests/acme-fulfillment/tracker.yaml b/examples/manifests/acme-fulfillment/tracker.yaml new file mode 100644 index 00000000..595d3b75 --- /dev/null +++ b/examples/manifests/acme-fulfillment/tracker.yaml @@ -0,0 +1,24 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Tracker +metadata: + name: fulfillment-buildout + namespace: acme-fulfillment +spec: + doc: Delivery tracking for the fulfillment model. The tracker points at the model; the model never points back. + items: + - doc: waiting on carrier sandbox credentials + status: TRACK_STATUS_BLOCKED + subject: + id: + namespace: acme-fulfillment + slug: mark-fulfilled-auto + version: '1' + kind: ENTITY_KIND_AUTOMATION_SLICE + - status: TRACK_STATUS_TODO + subject: + id: + namespace: acme-fulfillment + slug: order-tracking-ui-slice + version: '1' + kind: ENTITY_KIND_UI_SLICE + title: Fulfillment buildout diff --git a/examples/manifests/acme-storefront/ui-slice.yaml b/examples/manifests/acme-fulfillment/ui-slice.yaml similarity index 73% rename from examples/manifests/acme-storefront/ui-slice.yaml rename to examples/manifests/acme-fulfillment/ui-slice.yaml index a1d0f74a..cfd130a9 100644 --- a/examples/manifests/acme-storefront/ui-slice.yaml +++ b/examples/manifests/acme-fulfillment/ui-slice.yaml @@ -2,24 +2,24 @@ apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 kind: UiSlice metadata: name: order-tracking-ui-slice - namespace: acme-storefront + namespace: acme-fulfillment spec: persona: persona: id: - namespace: acme-storefront + namespace: acme-fulfillment slug: shopper version: '1' sourceReadModels: - readModel: id: - namespace: acme-storefront + namespace: acme-fulfillment slug: order-status version: '1' title: Order tracking display ui: ui: id: - namespace: acme-storefront + namespace: acme-fulfillment slug: order-tracking version: '1' diff --git a/examples/manifests/acme-fulfillment/ui.yaml b/examples/manifests/acme-fulfillment/ui.yaml new file mode 100644 index 00000000..6dc3bf42 --- /dev/null +++ b/examples/manifests/acme-fulfillment/ui.yaml @@ -0,0 +1,8 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Ui +metadata: + name: order-tracking + namespace: acme-fulfillment +spec: + doc: 'Shopper-facing: order status from placed through delivery.' + title: Order tracking diff --git a/examples/manifests/acme-reviews/bounded-context.yaml b/examples/manifests/acme-reviews/bounded-context.yaml index 0e25141c..7483c606 100644 --- a/examples/manifests/acme-reviews/bounded-context.yaml +++ b/examples/manifests/acme-reviews/bounded-context.yaml @@ -7,7 +7,15 @@ spec: doc: Owns review eligibility and review submission once a storefront order is delivered. realizes: - id: - namespace: acme-reviews + namespace: acme slug: seller-reputation version: '1' + relationships: + - doc: Reviews takes the order.fulfilled fact as Acme Fulfillment publishes it and adapts when it changes. + intent: INTENT_CONFORMIST + upstream: + id: + namespace: acme-fulfillment + slug: acme-fulfillment + version: '1' title: Acme Reviews diff --git a/examples/manifests/acme-reviews/domain.yaml b/examples/manifests/acme-reviews/domain.yaml deleted file mode 100644 index 474facff..00000000 --- a/examples/manifests/acme-reviews/domain.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Domain -metadata: - name: customer-trust - namespace: acme-reviews -spec: - doc: Keeping buyer confidence high after a sale closes, including seller reputation. - project: - id: - namespace: acme-reviews - slug: acme-reviews - version: '1' - title: Customer Trust diff --git a/examples/manifests/acme-reviews/event-model.yaml b/examples/manifests/acme-reviews/event-model.yaml index d6074339..0fdd582c 100644 --- a/examples/manifests/acme-reviews/event-model.yaml +++ b/examples/manifests/acme-reviews/event-model.yaml @@ -7,12 +7,12 @@ spec: doc: Review eligibility and review submission for delivered storefront orders. members: - id: - namespace: acme-reviews - slug: customer-trust + namespace: acme + slug: acme version: '1' kind: ENTITY_KIND_DOMAIN - id: - namespace: acme-reviews + namespace: acme slug: seller-reputation version: '1' kind: ENTITY_KIND_SUBDOMAIN diff --git a/examples/manifests/acme-reviews/project.yaml b/examples/manifests/acme-reviews/project.yaml deleted file mode 100644 index aed8b5ce..00000000 --- a/examples/manifests/acme-reviews/project.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Project -metadata: - name: acme-reviews - namespace: acme-reviews -spec: - doc: Review eligibility and submission for delivered storefront orders. - title: Acme Reviews diff --git a/examples/manifests/acme-reviews/read-model.yaml b/examples/manifests/acme-reviews/read-model.yaml index 79ae6229..db501b22 100644 --- a/examples/manifests/acme-reviews/read-model.yaml +++ b/examples/manifests/acme-reviews/read-model.yaml @@ -30,7 +30,7 @@ spec: string: {} sourceEvents: - id: - namespace: acme-storefront + namespace: acme-fulfillment slug: order.fulfilled version: '1' - id: diff --git a/examples/manifests/acme-reviews/storyboard.yaml b/examples/manifests/acme-reviews/storyboard.yaml index 41ca8608..bbc0e217 100644 --- a/examples/manifests/acme-reviews/storyboard.yaml +++ b/examples/manifests/acme-reviews/storyboard.yaml @@ -4,7 +4,7 @@ metadata: name: review-eligibility-grant namespace: acme-reviews spec: - doc: A delivered order clears the no-review window and becomes reviewable. It starts from the delivered-unreviewed queue that the storefront fulfillment event populates, so that projection is upstream preparation and not a step here. + doc: A delivered order clears the no-review window and becomes reviewable. It starts from the delivered-unreviewed queue that the fulfillment order.fulfilled event populates, so that projection is upstream preparation and not a step here. entry: automation: processor: diff --git a/examples/manifests/acme-reviews/subdomain.yaml b/examples/manifests/acme-reviews/subdomain.yaml deleted file mode 100644 index edcbb196..00000000 --- a/examples/manifests/acme-reviews/subdomain.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Subdomain -metadata: - name: seller-reputation - namespace: acme-reviews -spec: - classification: CLASSIFICATION_SUPPORTING - doc: Collecting buyer reviews of a seller once an order is delivered. - domain: - id: - namespace: acme-reviews - slug: customer-trust - version: '1' - title: Seller Reputation diff --git a/examples/manifests/acme-storefront/automation-slice.yaml b/examples/manifests/acme-storefront/automation-slice.yaml index 52ace08e..a827976f 100644 --- a/examples/manifests/acme-storefront/automation-slice.yaml +++ b/examples/manifests/acme-storefront/automation-slice.yaml @@ -41,47 +41,3 @@ spec: slug: pending-payments version: '1' title: Capture payment ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: AutomationSlice -metadata: - name: mark-fulfilled-auto - namespace: acme-storefront -spec: - doc: Calls Acme Shipping Carrier to dispatch the order and records delivery confirmation. - emittedCommand: - command: - id: - namespace: acme-storefront - slug: mark-fulfilled - version: '1' - processor: - processor: - id: - namespace: acme-storefront - slug: carrier-dispatch-worker - version: '1' - scenarios: - - doc: Queue row. - given: - - readModel: - id: - namespace: acme-storefront - slug: fulfillment-queue - version: '1' - id: ok - then: - command: - id: - namespace: acme-storefront - slug: mark-fulfilled - version: '1' - title: Delivery confirmed - whenDoc: Acme Shipping Carrier confirms delivery. - sourceReadModels: - - readModel: - id: - namespace: acme-storefront - slug: fulfillment-queue - version: '1' - title: Dispatch to carrier diff --git a/examples/manifests/acme-storefront/bounded-context.yaml b/examples/manifests/acme-storefront/bounded-context.yaml index b947d68f..90b42ce7 100644 --- a/examples/manifests/acme-storefront/bounded-context.yaml +++ b/examples/manifests/acme-storefront/bounded-context.yaml @@ -4,14 +4,10 @@ metadata: name: acme-storefront namespace: acme-storefront spec: - doc: Owns the cart-to-delivery lifecycle of a storefront order, the payment capture worker, and the carrier dispatch worker. + doc: Owns the cart-to-payment lifecycle of a storefront order and the payment capture worker. realizes: - id: - namespace: acme-storefront + namespace: acme slug: ordering version: '1' - - id: - namespace: acme-storefront - slug: fulfillment - version: '1' title: Acme Storefront diff --git a/examples/manifests/acme-storefront/command-slice.yaml b/examples/manifests/acme-storefront/command-slice.yaml index f7c9b241..7b548b75 100644 --- a/examples/manifests/acme-storefront/command-slice.yaml +++ b/examples/manifests/acme-storefront/command-slice.yaml @@ -147,47 +147,3 @@ spec: slug: capture-payment version: '1' title: Capture payment ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: CommandSlice -metadata: - name: mark-fulfilled-slice - namespace: acme-storefront -spec: - command: - command: - id: - namespace: acme-storefront - slug: mark-fulfilled - version: '1' - doc: Acme Shipping Carrier confirmed delivery. - emittedEvents: - - event: - id: - namespace: acme-storefront - slug: order.fulfilled - version: '1' - scenarios: - - doc: Delivery confirmed. - emit: - events: - - event: - id: - namespace: acme-storefront - slug: order.fulfilled - version: '1' - given: - - event: - id: - namespace: acme-storefront - slug: order.paid - version: '1' - id: delivered - title: Delivery confirmed - when: - command: - id: - namespace: acme-storefront - slug: mark-fulfilled - version: '1' - title: Mark fulfilled diff --git a/examples/manifests/acme-storefront/command.yaml b/examples/manifests/acme-storefront/command.yaml index 7b2d4404..b1892c99 100644 --- a/examples/manifests/acme-storefront/command.yaml +++ b/examples/manifests/acme-storefront/command.yaml @@ -92,36 +92,3 @@ spec: slug: order version: '1' title: Capture payment ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Command -metadata: - name: mark-fulfilled - namespace: acme-storefront -spec: - doc: Worker-issued. Records that Acme Shipping Carrier confirmed delivery. - schema: - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema - fields: - - name: order_id - type: - uuid: {} - - name: buyer_id - type: - uuid: {} - - name: seller_id - type: - uuid: {} - - metadata: - - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.DerivedFieldAnnotation - doc: now() when the worker records the outcome - kind: KIND_TIMESTAMP - name: fulfilled_at - type: - timestamp: {} - swimlane: - id: - namespace: acme-storefront - slug: fulfillment - version: '1' - title: Mark fulfilled diff --git a/examples/manifests/acme-storefront/component.yaml b/examples/manifests/acme-storefront/component.yaml index e3fce6c6..5a86cfb7 100644 --- a/examples/manifests/acme-storefront/component.yaml +++ b/examples/manifests/acme-storefront/component.yaml @@ -9,15 +9,3 @@ spec: - worker - queue title: Payment Capture Module ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Component -metadata: - name: carrier-dispatch-module - namespace: acme-storefront -spec: - doc: Code module housing the carrier-dispatch-worker's retry and backoff logic. - tech: - - worker - - queue - title: Carrier Dispatch Module diff --git a/examples/manifests/acme-storefront/domain.yaml b/examples/manifests/acme-storefront/domain.yaml deleted file mode 100644 index 11f381fc..00000000 --- a/examples/manifests/acme-storefront/domain.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Domain -metadata: - name: retail-commerce - namespace: acme-storefront -spec: - doc: Buying, paying for, and fulfilling physical goods sold through the Acme storefront. - project: - id: - namespace: acme-storefront - slug: acme-storefront - version: '1' - title: Retail Commerce diff --git a/examples/manifests/acme-storefront/event-model.yaml b/examples/manifests/acme-storefront/event-model.yaml index 348119d4..b4bb8f27 100644 --- a/examples/manifests/acme-storefront/event-model.yaml +++ b/examples/manifests/acme-storefront/event-model.yaml @@ -4,23 +4,18 @@ metadata: name: acme-storefront-model namespace: acme-storefront spec: - doc: 'End-to-end storefront flow: cart, checkout, payment capture, and carrier fulfillment.' + doc: 'Storefront ordering flow: cart, checkout, and payment capture.' members: - id: - namespace: acme-storefront - slug: retail-commerce + namespace: acme + slug: acme version: '1' kind: ENTITY_KIND_DOMAIN - id: - namespace: acme-storefront + namespace: acme slug: ordering version: '1' kind: ENTITY_KIND_SUBDOMAIN - - id: - namespace: acme-storefront - slug: fulfillment - version: '1' - kind: ENTITY_KIND_SUBDOMAIN - id: namespace: acme-storefront slug: acme-storefront @@ -31,21 +26,11 @@ spec: slug: acme-payments version: '1' kind: ENTITY_KIND_EXTERNAL_SYSTEM - - id: - namespace: acme-storefront - slug: acme-shipping-carrier - version: '1' - kind: ENTITY_KIND_EXTERNAL_SYSTEM - id: namespace: acme-storefront slug: payment-capture-module version: '1' kind: ENTITY_KIND_COMPONENT - - id: - namespace: acme-storefront - slug: carrier-dispatch-module - version: '1' - kind: ENTITY_KIND_COMPONENT - id: namespace: acme-storefront slug: shopper @@ -66,11 +51,6 @@ spec: slug: order version: '1' kind: ENTITY_KIND_SWIMLANE - - id: - namespace: acme-storefront - slug: fulfillment - version: '1' - kind: ENTITY_KIND_SWIMLANE - id: namespace: acme-storefront slug: cart.item-added @@ -86,11 +66,6 @@ spec: slug: order.paid version: '1' kind: ENTITY_KIND_EVENT - - id: - namespace: acme-storefront - slug: order.fulfilled - version: '1' - kind: ENTITY_KIND_EVENT - id: namespace: acme-storefront slug: add-item-to-cart @@ -106,11 +81,6 @@ spec: slug: capture-payment version: '1' kind: ENTITY_KIND_COMMAND - - id: - namespace: acme-storefront - slug: mark-fulfilled - version: '1' - kind: ENTITY_KIND_COMMAND - id: namespace: acme-storefront slug: cart-summary @@ -121,36 +91,16 @@ spec: slug: pending-payments version: '1' kind: ENTITY_KIND_READ_MODEL - - id: - namespace: acme-storefront - slug: fulfillment-queue - version: '1' - kind: ENTITY_KIND_READ_MODEL - - id: - namespace: acme-storefront - slug: order-status - version: '1' - kind: ENTITY_KIND_READ_MODEL - id: namespace: acme-storefront slug: payment-capture-worker version: '1' kind: ENTITY_KIND_PROCESSOR - - id: - namespace: acme-storefront - slug: carrier-dispatch-worker - version: '1' - kind: ENTITY_KIND_PROCESSOR - id: namespace: acme-storefront slug: storefront-checkout version: '1' kind: ENTITY_KIND_UI - - id: - namespace: acme-storefront - slug: order-tracking - version: '1' - kind: ENTITY_KIND_UI - id: namespace: acme-storefront slug: add-item-to-cart-slice @@ -166,11 +116,6 @@ spec: slug: capture-payment-slice version: '1' kind: ENTITY_KIND_COMMAND_SLICE - - id: - namespace: acme-storefront - slug: mark-fulfilled-slice - version: '1' - kind: ENTITY_KIND_COMMAND_SLICE - id: namespace: acme-storefront slug: cart-summary-rms @@ -183,7 +128,7 @@ spec: kind: ENTITY_KIND_READ_MODEL_SLICE - id: namespace: acme-storefront - slug: fulfillment-queue-rms + slug: pending-payments-delete-rms version: '1' kind: ENTITY_KIND_READ_MODEL_SLICE - id: @@ -191,16 +136,6 @@ spec: slug: capture-payment-auto version: '1' kind: ENTITY_KIND_AUTOMATION_SLICE - - id: - namespace: acme-storefront - slug: mark-fulfilled-auto - version: '1' - kind: ENTITY_KIND_AUTOMATION_SLICE - - id: - namespace: acme-storefront - slug: order-tracking-ui-slice - version: '1' - kind: ENTITY_KIND_UI_SLICE - id: namespace: acme-storefront slug: checkout-flow @@ -208,22 +143,7 @@ spec: kind: ENTITY_KIND_STORYBOARD - id: namespace: acme-storefront - slug: fulfillment-flow + slug: payment-flow version: '1' kind: ENTITY_KIND_STORYBOARD - - id: - namespace: acme-storefront - slug: order-status-placed-rms - version: '1' - kind: ENTITY_KIND_READ_MODEL_SLICE - - id: - namespace: acme-storefront - slug: order-status-paid-rms - version: '1' - kind: ENTITY_KIND_READ_MODEL_SLICE - - id: - namespace: acme-storefront - slug: order-status-fulfilled-rms - version: '1' - kind: ENTITY_KIND_READ_MODEL_SLICE title: Acme Storefront diff --git a/examples/manifests/acme-storefront/event.yaml b/examples/manifests/acme-storefront/event.yaml index 06959cfe..8e811114 100644 --- a/examples/manifests/acme-storefront/event.yaml +++ b/examples/manifests/acme-storefront/event.yaml @@ -84,32 +84,3 @@ spec: slug: order version: '1' title: Order paid ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Event -metadata: - name: order.fulfilled - namespace: acme-storefront -spec: - doc: Acme Shipping Carrier confirmed the order was delivered. - schema: - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema - fields: - - name: order_id - type: - uuid: {} - - name: buyer_id - type: - uuid: {} - - name: seller_id - type: - uuid: {} - - name: fulfilled_at - type: - timestamp: {} - swimlane: - id: - namespace: acme-storefront - slug: fulfillment - version: '1' - title: Order fulfilled diff --git a/examples/manifests/acme-storefront/external-system.yaml b/examples/manifests/acme-storefront/external-system.yaml index ace6a508..8d766d8e 100644 --- a/examples/manifests/acme-storefront/external-system.yaml +++ b/examples/manifests/acme-storefront/external-system.yaml @@ -9,14 +9,3 @@ spec: - card-network - webhook title: Acme Payments ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ExternalSystem -metadata: - name: acme-shipping-carrier - namespace: acme-storefront -spec: - doc: Acme Shipping Carrier. Dispatches parcels and confirms delivery over a polled tracking API. - tech: - - carrier-api - title: Acme Shipping Carrier diff --git a/examples/manifests/acme-storefront/processor.yaml b/examples/manifests/acme-storefront/processor.yaml index 6dd99ff3..06d50ca9 100644 --- a/examples/manifests/acme-storefront/processor.yaml +++ b/examples/manifests/acme-storefront/processor.yaml @@ -12,18 +12,3 @@ spec: doc: Captures the order's payment and records the outcome. docTrigger: A pending-payments queue row appears. title: Payment capture worker ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Processor -metadata: - name: carrier-dispatch-worker - namespace: acme-storefront -spec: - calls: - - id: - namespace: acme-storefront - slug: acme-shipping-carrier - version: '1' - doc: Dispatches the order to the carrier and records delivery confirmation. - docTrigger: A fulfillment queue row appears. - title: Carrier dispatch worker diff --git a/examples/manifests/acme-storefront/project.yaml b/examples/manifests/acme-storefront/project.yaml deleted file mode 100644 index 49fa0471..00000000 --- a/examples/manifests/acme-storefront/project.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Project -metadata: - name: acme-storefront - namespace: acme-storefront -spec: - doc: The Acme storefront, from cart and checkout to payment and fulfillment. - title: Acme Storefront diff --git a/examples/manifests/acme-storefront/read-model-slice.yaml b/examples/manifests/acme-storefront/read-model-slice.yaml index bbe186f2..4e4b4365 100644 --- a/examples/manifests/acme-storefront/read-model-slice.yaml +++ b/examples/manifests/acme-storefront/read-model-slice.yaml @@ -45,60 +45,16 @@ spec: apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 kind: ReadModelSlice metadata: - name: fulfillment-queue-rms + name: pending-payments-delete-rms namespace: acme-storefront spec: - doc: Pending row. - projectionRole: PROJECTION_ROLE_UPSERT - readModel: - readModel: - id: - namespace: acme-storefront - slug: fulfillment-queue - version: '1' - sourceEvents: - - event: - id: - namespace: acme-storefront - slug: order.paid - version: '1' - title: 'Queue: fulfillment' ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ReadModelSlice -metadata: - name: order-status-placed-rms - namespace: acme-storefront -spec: - doc: Status row on placement. - projectionRole: PROJECTION_ROLE_UPSERT - readModel: - readModel: - id: - namespace: acme-storefront - slug: order-status - version: '1' - sourceEvents: - - event: - id: - namespace: acme-storefront - slug: order.placed - version: '1' - title: 'Projection: order placed' ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ReadModelSlice -metadata: - name: order-status-paid-rms - namespace: acme-storefront -spec: - doc: Status row on payment. - projectionRole: PROJECTION_ROLE_UPSERT + doc: The order drops off the queue once its payment is captured. + projectionRole: PROJECTION_ROLE_DELETE readModel: readModel: id: namespace: acme-storefront - slug: order-status + slug: pending-payments version: '1' sourceEvents: - event: @@ -106,26 +62,4 @@ spec: namespace: acme-storefront slug: order.paid version: '1' - title: 'Projection: order paid' ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ReadModelSlice -metadata: - name: order-status-fulfilled-rms - namespace: acme-storefront -spec: - doc: Status row on delivery. - projectionRole: PROJECTION_ROLE_UPSERT - readModel: - readModel: - id: - namespace: acme-storefront - slug: order-status - version: '1' - sourceEvents: - - event: - id: - namespace: acme-storefront - slug: order.fulfilled - version: '1' - title: 'Projection: order fulfilled' + title: 'Queue: payment captured' diff --git a/examples/manifests/acme-storefront/read-model.yaml b/examples/manifests/acme-storefront/read-model.yaml index 6fb21f30..90a1cdc0 100644 --- a/examples/manifests/acme-storefront/read-model.yaml +++ b/examples/manifests/acme-storefront/read-model.yaml @@ -56,37 +56,6 @@ spec: - name: placed_at type: timestamp: {} - - name: status - type: - string: {} - sourceEvents: - - id: - namespace: acme-storefront - slug: order.placed - version: '1' - title: Pending payments (queue) ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ReadModel -metadata: - name: fulfillment-queue - namespace: acme-storefront -spec: - doc: 'Worker feed: paid orders awaiting carrier dispatch.' - key: - - order_id - schema: - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema - fields: - - name: order_id - type: - uuid: {} - - name: buyer_id - type: - uuid: {} - - name: seller_id - type: - uuid: {} - name: paid_at type: timestamp: {} @@ -94,49 +63,6 @@ spec: type: string: {} sourceEvents: - - id: - namespace: acme-storefront - slug: order.paid - version: '1' - title: Fulfillment queue ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: ReadModel -metadata: - name: order-status - namespace: acme-storefront -spec: - doc: Current status of an order for the shopper-facing tracking page. - key: - - order_id - schema: - '@type': type.googleapis.com/trogonatlas.eventmodel.v1alpha1.Schema - fields: - - name: order_id - type: - uuid: {} - - name: status - type: - string: {} - - name: cart_id - type: - uuid: {} - - name: buyer_id - type: - uuid: {} - - name: seller_id - type: - uuid: {} - - name: placed_at - type: - timestamp: {} - - name: paid_at - type: - timestamp: {} - - name: fulfilled_at - type: - timestamp: {} - sourceEvents: - id: namespace: acme-storefront slug: order.placed @@ -145,8 +71,4 @@ spec: namespace: acme-storefront slug: order.paid version: '1' - - id: - namespace: acme-storefront - slug: order.fulfilled - version: '1' - title: Order status + title: Pending payments (queue) diff --git a/examples/manifests/acme-storefront/storyboard.yaml b/examples/manifests/acme-storefront/storyboard.yaml index 10182ed8..eab79f26 100644 --- a/examples/manifests/acme-storefront/storyboard.yaml +++ b/examples/manifests/acme-storefront/storyboard.yaml @@ -49,19 +49,15 @@ spec: namespace: acme-storefront slug: pending-payments-rms version: '1' - - id: - namespace: acme-storefront - slug: order-status-placed-rms - version: '1' title: Checkout --- apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 kind: Storyboard metadata: - name: fulfillment-flow + name: payment-flow namespace: acme-storefront spec: - doc: A placed order is captured, dispatched, and confirmed delivered. It starts from the pending-payments queue that checkout-flow populated. + doc: A placed order has its payment captured. It starts from the pending-payments queue that checkout-flow populated. entry: automation: processor: @@ -81,7 +77,7 @@ spec: - event: id: namespace: acme-storefront - slug: order.fulfilled + slug: order.paid version: '1' slices: - id: @@ -94,26 +90,6 @@ spec: version: '1' - id: namespace: acme-storefront - slug: order-status-paid-rms - version: '1' - - id: - namespace: acme-storefront - slug: fulfillment-queue-rms - version: '1' - - id: - namespace: acme-storefront - slug: mark-fulfilled-auto - version: '1' - - id: - namespace: acme-storefront - slug: mark-fulfilled-slice - version: '1' - - id: - namespace: acme-storefront - slug: order-status-fulfilled-rms - version: '1' - - id: - namespace: acme-storefront - slug: order-tracking-ui-slice + slug: pending-payments-delete-rms version: '1' - title: Fulfillment + title: Payment diff --git a/examples/manifests/acme-storefront/subdomain.yaml b/examples/manifests/acme-storefront/subdomain.yaml deleted file mode 100644 index 26c9c5a2..00000000 --- a/examples/manifests/acme-storefront/subdomain.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Subdomain -metadata: - name: ordering - namespace: acme-storefront -spec: - classification: CLASSIFICATION_CORE - doc: Cart, checkout, and payment capture for a single order. - domain: - id: - namespace: acme-storefront - slug: retail-commerce - version: '1' - title: Ordering ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Subdomain -metadata: - name: fulfillment - namespace: acme-storefront -spec: - classification: CLASSIFICATION_SUPPORTING - doc: Dispatching a paid order to the shipping carrier and confirming delivery. - domain: - id: - namespace: acme-storefront - slug: retail-commerce - version: '1' - title: Fulfillment diff --git a/examples/manifests/acme-storefront/swimlane.yaml b/examples/manifests/acme-storefront/swimlane.yaml index 4c84b424..2a1d1f4a 100644 --- a/examples/manifests/acme-storefront/swimlane.yaml +++ b/examples/manifests/acme-storefront/swimlane.yaml @@ -30,21 +30,3 @@ spec: - paid streamId: order-{order_id} title: Order ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Swimlane -metadata: - name: fulfillment - namespace: acme-storefront -spec: - doc: Dispatch and delivery of a paid order. - order: 3 - state: - - name: status - type: - enumeration: - values: - - pending - - fulfilled - streamId: order-{order_id} - title: Fulfillment diff --git a/examples/manifests/acme-storefront/tracker.yaml b/examples/manifests/acme-storefront/tracker.yaml index f9c064bd..12bd5a49 100644 --- a/examples/manifests/acme-storefront/tracker.yaml +++ b/examples/manifests/acme-storefront/tracker.yaml @@ -27,19 +27,4 @@ spec: slug: capture-payment-auto version: '1' kind: ENTITY_KIND_AUTOMATION_SLICE - - doc: waiting on carrier sandbox credentials - status: TRACK_STATUS_BLOCKED - subject: - id: - namespace: acme-storefront - slug: mark-fulfilled-auto - version: '1' - kind: ENTITY_KIND_AUTOMATION_SLICE - - status: TRACK_STATUS_TODO - subject: - id: - namespace: acme-storefront - slug: order-tracking-ui-slice - version: '1' - kind: ENTITY_KIND_UI_SLICE title: Storefront buildout diff --git a/examples/manifests/acme-storefront/ui.yaml b/examples/manifests/acme-storefront/ui.yaml index 7af951e8..e89f9995 100644 --- a/examples/manifests/acme-storefront/ui.yaml +++ b/examples/manifests/acme-storefront/ui.yaml @@ -6,12 +6,3 @@ metadata: spec: doc: 'Shopper-facing: cart contents and the place-order button.' title: Storefront checkout ---- -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Ui -metadata: - name: order-tracking - namespace: acme-storefront -spec: - doc: 'Shopper-facing: order status from placed through delivery.' - title: Order tracking diff --git a/examples/manifests/acme-wishlist/bounded-context.yaml b/examples/manifests/acme-wishlist/bounded-context.yaml index 26d13397..0cba45f8 100644 --- a/examples/manifests/acme-wishlist/bounded-context.yaml +++ b/examples/manifests/acme-wishlist/bounded-context.yaml @@ -7,7 +7,7 @@ spec: doc: Owns each shopper's wishlist. realizes: - id: - namespace: acme-wishlist + namespace: acme slug: wishlists version: '1' title: Acme Wishlist diff --git a/examples/manifests/acme-wishlist/domain.yaml b/examples/manifests/acme-wishlist/domain.yaml deleted file mode 100644 index a039b55a..00000000 --- a/examples/manifests/acme-wishlist/domain.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Domain -metadata: - name: shopping - namespace: acme-wishlist -spec: - doc: Helping shoppers find and keep track of products they want. - project: - id: - namespace: acme-wishlist - slug: acme-wishlist - version: '1' - title: Shopping diff --git a/examples/manifests/acme-wishlist/event-model.yaml b/examples/manifests/acme-wishlist/event-model.yaml index d87305ea..73e84b53 100644 --- a/examples/manifests/acme-wishlist/event-model.yaml +++ b/examples/manifests/acme-wishlist/event-model.yaml @@ -7,12 +7,12 @@ spec: doc: A shopper saves products to a wishlist. members: - id: - namespace: acme-wishlist - slug: shopping + namespace: acme + slug: acme version: '1' kind: ENTITY_KIND_DOMAIN - id: - namespace: acme-wishlist + namespace: acme slug: wishlists version: '1' kind: ENTITY_KIND_SUBDOMAIN diff --git a/examples/manifests/acme-wishlist/project.yaml b/examples/manifests/acme-wishlist/project.yaml deleted file mode 100644 index a61f855e..00000000 --- a/examples/manifests/acme-wishlist/project.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Project -metadata: - name: acme-wishlist - namespace: acme-wishlist -spec: - doc: The smallest valid Acme event model, a shopper's wishlist. - title: Acme Wishlist diff --git a/examples/manifests/acme-wishlist/subdomain.yaml b/examples/manifests/acme-wishlist/subdomain.yaml deleted file mode 100644 index 1c749e21..00000000 --- a/examples/manifests/acme-wishlist/subdomain.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 -kind: Subdomain -metadata: - name: wishlists - namespace: acme-wishlist -spec: - classification: CLASSIFICATION_CORE - doc: Saving products a shopper wants to buy later. - domain: - id: - namespace: acme-wishlist - slug: shopping - version: '1' - title: Wishlists diff --git a/examples/manifests/acme/domain.yaml b/examples/manifests/acme/domain.yaml new file mode 100644 index 00000000..e4b2c36d --- /dev/null +++ b/examples/manifests/acme/domain.yaml @@ -0,0 +1,13 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Domain +metadata: + name: acme + namespace: acme +spec: + doc: Selling, paying for, and fulfilling physical goods online, keeping buyer confidence high after the sale, helping shoppers keep track of products they want, and honoring requests to delete the personal data Acme holds. + project: + id: + namespace: acme + slug: acme + version: '1' + title: Acme diff --git a/examples/manifests/acme/project.yaml b/examples/manifests/acme/project.yaml new file mode 100644 index 00000000..c6900af1 --- /dev/null +++ b/examples/manifests/acme/project.yaml @@ -0,0 +1,8 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Project +metadata: + name: acme + namespace: acme +spec: + doc: The Acme online retail business, from the storefront and fulfillment to reviews, wishlists, and customer data deletion. + title: Acme diff --git a/examples/manifests/acme/subdomain.yaml b/examples/manifests/acme/subdomain.yaml new file mode 100644 index 00000000..4eaf8dee --- /dev/null +++ b/examples/manifests/acme/subdomain.yaml @@ -0,0 +1,74 @@ +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Subdomain +metadata: + name: ordering + namespace: acme +spec: + classification: CLASSIFICATION_CORE + doc: Cart, checkout, and payment capture for a single order. + domain: + id: + namespace: acme + slug: acme + version: '1' + title: Ordering +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Subdomain +metadata: + name: fulfillment + namespace: acme +spec: + classification: CLASSIFICATION_SUPPORTING + doc: Dispatching a paid order to the shipping carrier and confirming delivery. + domain: + id: + namespace: acme + slug: acme + version: '1' + title: Fulfillment +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Subdomain +metadata: + name: seller-reputation + namespace: acme +spec: + classification: CLASSIFICATION_SUPPORTING + doc: Collecting buyer reviews of a seller once an order is delivered. + domain: + id: + namespace: acme + slug: acme + version: '1' + title: Seller Reputation +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Subdomain +metadata: + name: data-deletion + namespace: acme +spec: + classification: CLASSIFICATION_SUPPORTING + doc: Coordinating deletion of a customer's personal data across every downstream vendor that holds it. + domain: + id: + namespace: acme + slug: acme + version: '1' + title: Data Deletion +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Subdomain +metadata: + name: wishlists + namespace: acme +spec: + classification: CLASSIFICATION_CORE + doc: Saving products a shopper wants to buy later. + domain: + id: + namespace: acme + slug: acme + version: '1' + title: Wishlists diff --git a/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto b/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto index 473a9959..31f4648d 100644 --- a/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto +++ b/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto @@ -302,6 +302,14 @@ message DeleteEntityResponse { repeated Reference referrers = 1; // Set only when the request carried an operation_id. trogonatlas.catalog.v1alpha1.OperationReceipt operation_receipt = 2; + // Strategic findings the delete causes on the entities left behind, for + // example REALIZES_DANGLING on a bounded context whose subdomain is gone or + // SUBDOMAIN_UNREALIZED on a subdomain whose only realizing context is gone. + // Populated when the delete proceeds (including FORCE) and, for DRY_RUN, as + // a preview of what the delete would leave. Advisory at every severity: they + // never block the delete. Empty on an operation_id replay, which has no + // current view to judge. + repeated ValidationIssue validation = 3; } // ---------- Batch mutation (atomic multi-entity write) ----------------------- @@ -1028,17 +1036,21 @@ message MergeBranchResponse { // At least one entry is a conflict class; nothing was persisted. // See `conflicts`. STATUS_CONFLICTS = 3; - // No conflicts, but the post-merge world fails validation with at - // least one Error-severity issue; nothing was persisted. See - // `validation`. + // No conflicts, but the post-merge world fails a blocking validation + // check with at least one Error-severity issue; nothing was persisted. + // See `validation`. Strategic findings never cause this status. STATUS_INVALID = 4; } Status status = 1; repeated trogonatlas.catalog.v1alpha1.BranchDiffEntry conflicts = 2; - // Post-merge validation findings. STATUS_INVALID carries the findings that - // blocked the merge. A dry run that reports STATUS_VALIDATED also carries - // its Warning and Info findings, so a reviewer sees what the merge leaves - // behind without a separate validation call. + // Post-merge validation findings. Blocking errors (the store-level checks + // of the post-merge world) produce STATUS_INVALID and carry the findings + // that blocked the merge. Strategic findings about bounded contexts, + // domains and subdomains, including those a landed deletion leaves behind, + // ride along without blocking, at any severity, exactly as they do on + // PutEntityResponse: STATUS_VALIDATED (a dry run) and STATUS_APPLIED carry + // them, so a reviewer sees what the merge leaves behind without a separate + // validation call. repeated ValidationIssue validation = 3; // Number of non-CONVERGED entries actually landed on baseline, or that a // dry run would land. diff --git a/rsworkspace/crates/trogon-atlas-cli/README.md b/rsworkspace/crates/trogon-atlas-cli/README.md index 9eaa09ab..07797d88 100644 --- a/rsworkspace/crates/trogon-atlas-cli/README.md +++ b/rsworkspace/crates/trogon-atlas-cli/README.md @@ -17,7 +17,7 @@ clap CLI on top of it, so the MCP adapter can build on the same semantics. ```sh trogon-atlas apply -f manifests/ # create + configure + skip unchanged -trogon-atlas apply -f manifests/ --dry-run # server-side validation, nothing persisted +trogon-atlas apply -f manifests/ --dry-run # request-time findings (strategic warnings included), nothing persisted trogon-atlas diff -f manifests/ # unified diff vs live state, exit 1 on drift trogon-atlas export --namespace shop -o manifests # live store -> manifest YAML (one file per kind) trogon-atlas export --namespace shop -o manifests --layout slices # one self-contained file per slice @@ -30,7 +30,10 @@ trogon-atlas describe command_slice/shop/place-order-slice # title, doc, fields, trogon-atlas tree shop # domains, storyboards, slices and what each slice connects trogon-atlas tree # the same for every namespace that holds entities trogon-atlas namespaces # namespaces you can see, with ids and entity counts -trogon-atlas validate shop/checkout # model-level findings, exit 1 on any error +trogon-atlas validate shop/checkout # full model validation incl. the strategic chain, exit 1 on any error +trogon-atlas validate-project --namespace shop # every model in a namespace plus store-level strategic findings +trogon-atlas validate-project --domain shop/shop # the models a Domain claims +trogon-atlas validate-project --project shop/main@2 # the models a Project claims ``` `get`, `describe`, `tree` and `namespaces` are read-only. They take the same kind names @@ -38,12 +41,27 @@ as manifests (`read-model`, `read_model`, `ReadModel`). A missing entity exits `6` (`not_found`). They also take the ids that `describe` prints, such as `event:shop/order.placed@1`. -`validate /[@version]` runs the model-level rules on a -stored event model: storyboard entries, projection order, field provenance. -`apply --dry-run` runs only the per-entity checks, so run `validate` after an -apply to see these findings. With `--branch`, it validates the branch's view -of the model. It exits `1` when any finding is an error and `6` when the -model does not exist. +`validate /[@version]` runs full validation on a stored +event model: the model-level rules (storyboard entries, projection order, +field provenance) and the strategic chain of the model's namespace, meaning +its BoundedContext, the Subdomains it realizes, their Domains, and any Domain +declared in the namespace. `apply --dry-run` returns only the request-time +findings for each write, strategic warnings included (they never block the +apply), so run `validate` after an apply to see the whole-model findings. +With `--branch`, it validates the branch's view of the model. It exits `1` +when any finding is an error and `6` when the model does not exist. + +`validate-project` validates every event model in a scope in one call. Pass +exactly one of `--namespace `, `--domain ` or +`--project `; omit `@version` for the latest. Findings +print per model, then once more in a `store-level` section: the strategic +findings (BoundedContext, Domain, Subdomain and Project rules) belong to no +model, so they appear there. A namespace scope also covers problem-space +namespaces that hold no event model, which is why to run it after editing +only Project, Domain, Subdomain or BoundedContext entities. It exits `1` when +any finding is an error. With `--format json`, `data` carries `scope`, +`validation_summary` and `reports`, one entry per section with `event_model` +(`null` for the store-level section), `validation_summary` and `validation`. Connection flags (or environment): diff --git a/rsworkspace/crates/trogon-atlas-cli/src/main.rs b/rsworkspace/crates/trogon-atlas-cli/src/main.rs index 272f9e65..a47b16c8 100644 --- a/rsworkspace/crates/trogon-atlas-cli/src/main.rs +++ b/rsworkspace/crates/trogon-atlas-cli/src/main.rs @@ -79,6 +79,10 @@ enum Cmd { #[arg(short = 'f', long = "filename", required = true, num_args = 1..)] filenames: Vec, /// Run server-side validation for every change without persisting. + /// Reports the request-time findings for each write, including the + /// strategic ones (BoundedContext, Domain, Subdomain rules), which + /// are warnings and never block the apply. Findings that need the + /// whole model in view come from `validate` and `validate-project`. #[arg(long)] dry_run: bool, /// Idempotency key for the batch this apply sends; omit to mint a @@ -169,15 +173,43 @@ enum Cmd { }, /// List the namespaces the caller can see, with their ids and entity counts. Namespaces, - /// Run model-level validation on a stored event model: the rules that - /// need the whole model in view, which `apply --dry-run` does not run. - /// Exits 1 when any finding is an error. With --branch, validates the - /// branch's view of the model. + /// Run full validation on a stored event model: the rules that need the + /// whole model in view, which `apply --dry-run` does not run, plus the + /// strategic chain of the model's namespace (its BoundedContext, the + /// Subdomains it realizes, their Domains, and Domains declared in the + /// namespace). Exits 1 when any finding is an error. With --branch, + /// validates the branch's view of the model. Validate { /// `/[@version]`, e.g. `shop/checkout`. Omit /// `@version` to validate the latest version. model: String, }, + /// Validate every event model in a namespace, domain or project, plus + /// the strategic findings (BoundedContext, Domain, Subdomain, Project + /// rules), which print once in a store-level section. Use it after + /// editing only problem-space entities or a BoundedContext, when there + /// may be no event model to run `validate` on. Exits 1 when any + /// finding is an error. With --branch, validates the branch's view. + #[command(group( + clap::ArgGroup::new("scope") + .required(true) + .multiple(false) + .args(["namespace", "domain", "project"]) + ))] + ValidateProject { + /// Every event model in this namespace, problem-space namespaces + /// (those with no event model) included. + #[arg(long)] + namespace: Option, + /// `/[@version]` of a Domain; omit `@version` for + /// the latest. + #[arg(long)] + domain: Option, + /// `/[@version]` of a Project; omit `@version` for + /// the latest. + #[arg(long)] + project: Option, + }, /// Manage branch lifecycle (Phase 1: Isolation). Branch scope for /// Apply/Diff/Export is set via the top-level --branch flag, not here. Branch { @@ -354,6 +386,86 @@ enum BranchCmd { }, } +/// One section of a `validate-project` run: a model's findings, or the +/// store-level findings (strategic rules) that belong to no event model. +struct ProjectSection { + /// Canonical event model id; `None` for the store-level section. + event_model: Option, + counts: branch::SeverityCounts, + issues: Vec, +} + +struct ProjectValidation { + scope: String, + total: branch::SeverityCounts, + sections: Vec, +} + +impl ProjectValidation { + fn of(scope: &str, resp: &pb::ValidateProjectResponse) -> Self { + let sections: Vec = resp + .reports + .iter() + .map(|report| ProjectSection { + event_model: report + .event_model_id + .as_ref() + .filter(|id| !id.slug.is_empty()) + .map(|id| pb::canonical::id_string(pb::EntityKind::EventModel, id)), + counts: branch::SeverityCounts::of(&report.issues), + issues: report.issues.clone(), + }) + .collect(); + let total = + sections + .iter() + .fold(branch::SeverityCounts::default(), |mut total, section| { + total.errors += section.counts.errors; + total.warnings += section.counts.warnings; + total.infos += section.counts.infos; + total + }); + Self { + scope: scope.to_string(), + total, + sections, + } + } + + fn render(&self) -> String { + use std::fmt::Write as _; + let mut out = format!("{}: {}\n", self.scope, self.total); + for section in &self.sections { + let name = section.event_model.as_deref().unwrap_or("store-level"); + let _ = writeln!(out, "{name}: {}", section.counts); + for issue in §ion.issues { + let _ = writeln!(out, "{}", apply::format_issue(issue)); + } + } + out + } + + fn to_json(&self) -> Value { + json!({ + "scope": self.scope, + "validation_summary": summary_json(self.total), + "reports": self.sections.iter().map(|section| json!({ + "event_model": section.event_model, + "validation_summary": summary_json(section.counts), + "validation": section.issues.iter().map(apply::issue_to_json).collect::>(), + })).collect::>(), + }) + } +} + +fn summary_json(counts: branch::SeverityCounts) -> Value { + json!({ + "errors": counts.errors, + "warnings": counts.warnings, + "info": counts.infos, + }) +} + /// Exit code for a command whose output reports something short of success /// without being an error: drift a diff found, issues apply reported, a /// branch's remaining conflicts. Also used by `trogon-atlas fmt --check`. @@ -941,6 +1053,40 @@ async fn run( Some(data), )) } + Cmd::ValidateProject { + namespace, + domain, + project, + } => { + let (label, scope) = match (namespace, domain, project) { + (Some(ns), None, None) => ( + format!("namespace {ns}"), + validate::ProjectScope::namespace(&ns)?, + ), + (None, Some(d), None) => { + (format!("domain {d}"), validate::ProjectScope::domain(&d)?) + } + (None, None, Some(p)) => { + (format!("project {p}"), validate::ProjectScope::project(&p)?) + } + _ => unreachable!("clap requires exactly one of --namespace, --domain, --project"), + }; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let resp = validate::validate_project(&mut client, &scope).await?; + let report = ProjectValidation::of(&label, &resp); + if text { + print!("{}", report.render()); + } + Ok(( + if report.total.errors > 0 { + ExitCode::from(EXIT_FINDINGS) + } else { + ExitCode::SUCCESS + }, + Some(report.to_json()), + )) + } Cmd::Tree { namespace } => { if let Some(namespace) = &namespace { trogon_atlas_core::validate_id_component(namespace).map_err(|e| { @@ -1169,8 +1315,15 @@ async fn run( use pb::merge_branch_response::Status; match Status::try_from(resp.status).unwrap_or(Status::Unspecified) { Status::Applied => { + let severities = branch::SeverityCounts::of(&resp.validation); if text { println!("{name} merged ({} entities applied)", resp.applied_count); + if !resp.validation.is_empty() { + println!("validation after merge: {severities}"); + for issue in &resp.validation { + println!("{}", apply::format_issue(issue)); + } + } } let counts = branch::MergeCounts::of(&resp); let data = json!({ @@ -1179,6 +1332,12 @@ async fn run( "added_count": counts.added, "changed_count": counts.changed, "deleted_count": counts.deleted, + "validation_summary": { + "errors": severities.errors, + "warnings": severities.warnings, + "info": severities.infos, + }, + "validation": resp.validation.iter().map(apply::issue_to_json).collect::>(), }); Ok((ExitCode::SUCCESS, Some(data))) } @@ -1352,10 +1511,12 @@ async fn run( } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use trogon_atlas_client::failure::FailureCategory as Category; + use trogon_atlas_proto as pb; - use super::exit_code_for_category; + use super::{exit_code_for_category, ProjectValidation}; // `ExitCode` has no public accessor for the raw code, so compare // through `Debug` formatting against `ExitCode::from(expected)` built @@ -1385,4 +1546,76 @@ mod tests { ); } } + + fn issue(severity: pb::validation_issue::Severity, code: &str) -> pb::ValidationIssue { + pb::ValidationIssue { + severity: severity as i32, + code: code.into(), + message: "m".into(), + ..Default::default() + } + } + + fn project_response() -> pb::ValidateProjectResponse { + use pb::validation_issue::Severity; + pb::ValidateProjectResponse { + reports: vec![ + pb::validate_project_response::ModelReport { + event_model_id: Some(pb::Id { + namespace: "shop".into(), + slug: "checkout".into(), + version: 1, + }), + issues: vec![issue(Severity::Info, "OPEN_QUESTION_PRESENT")], + }, + pb::validate_project_response::ModelReport { + event_model_id: None, + issues: vec![ + issue(Severity::Warning, "DOMAIN_IN_CONTEXT_NAMESPACE"), + issue(Severity::Error, "DOMAIN_SLUG_MISMATCH"), + ], + }, + ], + ..Default::default() + } + } + + #[test] + fn project_validation_prints_per_model_and_store_level_sections() { + let report = ProjectValidation::of("namespace shop", &project_response()); + assert_eq!(report.total.errors, 1); + assert_eq!(report.total.warnings, 1); + assert_eq!(report.total.infos, 1); + let text = report.render(); + assert!( + text.starts_with("namespace shop: 1 errors, 1 warnings, 1 info\n"), + "{text}" + ); + assert!( + text.contains("event_model:shop/checkout@1: 0 errors, 0 warnings, 1 info"), + "{text}" + ); + assert!( + text.contains("store-level: 1 errors, 1 warnings, 0 info"), + "{text}" + ); + assert!( + text.contains("warning DOMAIN_IN_CONTEXT_NAMESPACE"), + "{text}" + ); + } + + #[test] + fn project_validation_json_keeps_store_level_report_with_null_model() { + let json = ProjectValidation::of("namespace shop", &project_response()).to_json(); + assert_eq!(json["scope"], "namespace shop"); + assert_eq!(json["validation_summary"]["errors"], 1); + let reports = json["reports"].as_array().unwrap(); + assert_eq!(reports[0]["event_model"], "event_model:shop/checkout@1"); + assert!(reports[1]["event_model"].is_null()); + assert_eq!( + reports[1]["validation"][0]["code"], + "DOMAIN_IN_CONTEXT_NAMESPACE" + ); + } } diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs index 67451c8e..23c0af26 100644 --- a/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs +++ b/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs @@ -437,3 +437,69 @@ fn tempfile_dir() -> tempfile::TempDir { .tempdir() .unwrap() } + +// An applied merge carries strategic findings (non-blocking at any severity). +// Depends on the server returning them on STATUS_APPLIED. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trogon_atlas_branch_merge_applied_prints_and_returns_strategic_findings() { + let endpoint = start_server().await; + let ns = "cli-merge-strategic"; + + let created = trogon_atlas(&endpoint, &["branch", "create", "cli-merge-strategic"]); + assert!(created.status.success(), "{:?}", created.stderr); + + let dir = tempfile_dir(); + let manifest_path = dir.path().join("strategic.yaml"); + std::fs::write( + &manifest_path, + format!( + r" +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: BoundedContext +metadata: {{ namespace: {ns}, name: {ns} }} +spec: {{ title: Shop }} +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Domain +metadata: {{ namespace: {ns}, name: {ns} }} +spec: {{ title: Shop }} +" + ), + ) + .unwrap(); + let applied = trogon_atlas_branched( + &endpoint, + "cli-merge-strategic", + &["apply", "-f", manifest_path.to_str().unwrap()], + ); + assert!(applied.status.success(), "{:?}", applied.stderr); + + let merged = trogon_atlas( + &endpoint, + &[ + "--format", + "json", + "branch", + "merge", + "cli-merge-strategic", + "--keep-branch", + ], + ); + assert_eq!(merged.status.code(), Some(0), "{merged:?}"); + let doc: serde_json::Value = serde_json::from_str(&merged.stdout).unwrap(); + assert_eq!(doc["data"]["status"], "applied", "{doc}"); + assert!( + doc["data"]["validation_summary"]["warnings"] + .as_u64() + .unwrap() + >= 1, + "{doc}" + ); + let codes: Vec<_> = doc["data"]["validation"] + .as_array() + .unwrap() + .iter() + .map(|issue| issue["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"DOMAIN_IN_CONTEXT_NAMESPACE"), "{doc}"); +} diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs index 91ad5195..bd2d16c3 100644 --- a/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs +++ b/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs @@ -192,3 +192,153 @@ async fn validate_reads_the_model_from_the_given_branch() { let on_baseline = trogon_atlas(&endpoint, &["validate", "cli-validate-br/shop"]); assert_eq!(on_baseline.status.code(), Some(6), "{on_baseline:?}"); } + +fn ok_data(output: &std::process::Output) -> serde_json::Value { + let outcome: serde_json::Value = serde_json::from_str(&stdout(output)).unwrap(); + assert_eq!(outcome["ok"], true, "{outcome}"); + outcome["data"].clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_project_reports_per_model_errors_for_a_namespace_and_exits_with_findings() { + let endpoint = start_server().await; + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + seed_model_with_laneless_event(&mut client, "cli-vp-err").await; + + let text = trogon_atlas( + &endpoint, + &["validate-project", "--namespace", "cli-vp-err"], + ); + assert_eq!(text.status.code(), Some(1), "{text:?}"); + let out = stdout(&text); + assert!(out.contains("namespace cli-vp-err:"), "{out}"); + assert!(out.contains("event_model:cli-vp-err/shop@1:"), "{out}"); + assert!(out.contains("error EVENT_NO_SWIMLANE"), "{out}"); + + let json = trogon_atlas( + &endpoint, + &[ + "--format", + "json", + "validate-project", + "--namespace", + "cli-vp-err", + ], + ); + assert_eq!(json.status.code(), Some(1), "{json:?}"); + let data = ok_data(&json); + assert_eq!(data["scope"], "namespace cli-vp-err"); + assert!(data["validation_summary"]["errors"].as_u64().unwrap() >= 1); + let report = data["reports"] + .as_array() + .unwrap() + .iter() + .find(|r| r["event_model"] == "event_model:cli-vp-err/shop@1") + .unwrap_or_else(|| panic!("no per-model report in {data}")); + let codes: Vec<_> = report["validation"] + .as_array() + .unwrap() + .iter() + .map(|issue| issue["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"EVENT_NO_SWIMLANE"), "{data}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_project_exits_zero_for_a_clean_namespace() { + let endpoint = start_server().await; + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + put( + &mut client, + pb::entity::Kind::EventModel(pb::EventModel { + id: Some(id("cli-vp-ok", "empty")), + title: "Empty".into(), + ..Default::default() + }), + ) + .await; + + let out = trogon_atlas(&endpoint, &["validate-project", "--namespace", "cli-vp-ok"]); + assert!(out.status.success(), "{out:?}"); + assert!(stdout(&out).contains("0 errors"), "{}", stdout(&out)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_project_requires_exactly_one_scope() { + let endpoint = start_server().await; + let none = trogon_atlas(&endpoint, &["validate-project"]); + assert_eq!(none.status.code(), Some(2), "{none:?}"); + let two = trogon_atlas( + &endpoint, + &[ + "validate-project", + "--namespace", + "shop", + "--domain", + "shop/shop", + ], + ); + assert_eq!(two.status.code(), Some(2), "{two:?}"); + let malformed = trogon_atlas(&endpoint, &["validate-project", "--domain", "shop"]); + assert!(!malformed.status.success(), "{malformed:?}"); + assert_ne!(malformed.status.code(), Some(2), "{malformed:?}"); +} + +// Depends on the server returning strategic findings from ValidateProject: +// a Domain declared next to a BoundedContext warns +// DOMAIN_IN_CONTEXT_NAMESPACE on the store-level report (no event model id). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_project_reports_strategic_findings_in_the_store_level_section() { + let endpoint = start_server().await; + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + let ns = "cli-vp-strategic"; + put( + &mut client, + pb::entity::Kind::BoundedContext(pb::BoundedContext { + id: Some(id(ns, ns)), + title: "Checkout".into(), + ..Default::default() + }), + ) + .await; + put( + &mut client, + pb::entity::Kind::Domain(pb::Domain { + id: Some(id(ns, ns)), + title: "Shop".into(), + ..Default::default() + }), + ) + .await; + + let text = trogon_atlas(&endpoint, &["validate-project", "--namespace", ns]); + let out = stdout(&text); + assert!(out.contains("store-level:"), "{out}"); + assert!(out.contains("warning DOMAIN_IN_CONTEXT_NAMESPACE"), "{out}"); + + let domain = format!("{ns}/{ns}"); + let json = trogon_atlas( + &endpoint, + &["--format", "json", "validate-project", "--domain", &domain], + ); + let data = ok_data(&json); + let store_level = data["reports"] + .as_array() + .unwrap() + .iter() + .find(|r| r["event_model"].is_null()) + .unwrap_or_else(|| panic!("no store-level report in {data}")); + let codes: Vec<_> = store_level["validation"] + .as_array() + .unwrap() + .iter() + .map(|issue| issue["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"DOMAIN_IN_CONTEXT_NAMESPACE"), "{data}"); +} diff --git a/rsworkspace/crates/trogon-atlas-client/src/apply.rs b/rsworkspace/crates/trogon-atlas-client/src/apply.rs index 413af1a2..c24fc2a2 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/apply.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/apply.rs @@ -283,16 +283,50 @@ pub fn issue_to_json(issue: &pb::ValidationIssue) -> Value { }) } +/// The validation findings one batch op produced, with the op's position in +/// the batch and the canonical id of the entity it wrote. +pub struct OpIssues { + /// Zero-based position among the batch's ops (unchanged manifests are + /// not sent, so this is not the manifest index). + pub op_index: usize, + pub op_entity: String, + pub issues: Vec, +} + pub struct ApplyOutcome { /// One human-readable line per manifest, in manifest order. pub lines: Vec, /// Validation issues the server reported for applied/validated ops. pub issues: Vec, + /// The same findings grouped by the op that produced them; ops with no + /// findings are omitted. + pub op_issues: Vec, /// The batch's idempotency receipt, present only when the caller passed /// an `operation_id` to `apply` and at least one op ran. pub operation_receipt: Option, } +/// Groups the findings of put and delete results by op, tagging each group +/// with the op's position and the entity label at that position. +fn issues_by_op(results: &[pb::BatchMutateOpResult], labels: &[String]) -> Vec { + results + .iter() + .enumerate() + .filter_map(|(op_index, result)| { + let findings = match &result.result { + Some(pb::batch_mutate_op_result::Result::Put(put)) => &put.validation, + Some(pb::batch_mutate_op_result::Result::Delete(delete)) => &delete.validation, + None => return None, + }; + (!findings.is_empty()).then(|| OpIssues { + op_index, + op_entity: labels.get(op_index).cloned().unwrap_or_default(), + issues: findings.clone(), + }) + }) + .collect() +} + fn id_label(m: &LoadedManifest) -> String { pb::canonical::id_string(m.kind, &m.id) } @@ -318,6 +352,7 @@ pub async fn apply( .collect(); let mut issues = Vec::new(); + let mut op_issues = Vec::new(); let mut operation_receipt = None; if !changed.is_empty() { let ops = changed @@ -382,11 +417,12 @@ pub async fn apply( "apply returned unexpected status {status:?}, expected {expected:?} (dry_run={dry_run})" ); } - for result in &resp.results { - if let Some(pb::batch_mutate_op_result::Result::Put(put)) = &result.result { - issues.extend(put.validation.iter().cloned()); - } - } + let labels: Vec = changed.iter().map(|c| id_label(&c.manifest)).collect(); + op_issues = issues_by_op(&resp.results, &labels); + issues = op_issues + .iter() + .flat_map(|o| o.issues.iter().cloned()) + .collect(); } let lines = plan @@ -396,6 +432,7 @@ pub async fn apply( Ok(ApplyOutcome { lines, issues, + op_issues, operation_receipt, }) } @@ -604,4 +641,62 @@ mod tests { assert_eq!(json["issues"][0]["code"], "ENTITY_REFERENCED"); assert_eq!(json["issues"][0]["severity"], "error"); } + + fn finding(code: &str) -> pb::ValidationIssue { + pb::ValidationIssue { + severity: pb::validation_issue::Severity::Warning as i32, + code: code.into(), + ..Default::default() + } + } + + #[test] + fn issues_by_op_tags_put_and_delete_findings_and_skips_clean_ops() { + use pb::batch_mutate_op_result::Result as OpResult; + let results = vec![ + pb::BatchMutateOpResult { + result: Some(OpResult::Put(pb::PutEntityResponse { + validation: vec![finding("REALIZES_MIXED_CLASSIFICATIONS")], + ..Default::default() + })), + }, + pb::BatchMutateOpResult { + result: Some(OpResult::Put(pb::PutEntityResponse::default())), + }, + pb::BatchMutateOpResult { + result: Some(OpResult::Delete(pb::DeleteEntityResponse { + validation: vec![finding("REALIZES_DANGLING")], + ..Default::default() + })), + }, + pb::BatchMutateOpResult { result: None }, + ]; + let labels = vec![ + "bounded_context:shop/shop@1".to_string(), + "event:shop/order.placed@1".to_string(), + "subdomain:acme/ordering@1".to_string(), + ]; + + let grouped = issues_by_op(&results, &labels); + + let tagged: Vec<(usize, &str, &str)> = grouped + .iter() + .flat_map(|o| { + o.issues + .iter() + .map(move |i| (o.op_index, o.op_entity.as_str(), i.code.as_str())) + }) + .collect(); + assert_eq!( + tagged, + vec![ + ( + 0, + "bounded_context:shop/shop@1", + "REALIZES_MIXED_CLASSIFICATIONS" + ), + (2, "subdomain:acme/ordering@1", "REALIZES_DANGLING"), + ] + ); + } } diff --git a/rsworkspace/crates/trogon-atlas-client/src/branch.rs b/rsworkspace/crates/trogon-atlas-client/src/branch.rs index cdad5144..b5312525 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/branch.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/branch.rs @@ -171,8 +171,11 @@ impl std::fmt::Display for SeverityCounts { } /// Merge a branch onto baseline. All-or-nothing: any conflict (see -/// `resp.conflicts`) or post-merge validation Error (see `resp.validation`) -/// persists nothing. `operation_id` is dropped when `options.dry_run` is +/// `resp.conflicts`) persists nothing, and so do blocking post-merge +/// validation errors, which yield `STATUS_INVALID` with the findings in +/// `resp.validation`. Strategic findings (BoundedContext, Domain, Subdomain +/// rules) ride along in `resp.validation` at any severity on every status, +/// an applied merge included, and never block it. `operation_id` is dropped when `options.dry_run` is /// set: the server rejects an `operation_id` paired with `dry_run`, since a /// dry run never produces a write for a later replay to recover. pub async fn merge_branch( diff --git a/rsworkspace/crates/trogon-atlas-client/src/validate.rs b/rsworkspace/crates/trogon-atlas-client/src/validate.rs index 831a1d70..60f24dc7 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/validate.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/validate.rs @@ -50,6 +50,83 @@ pub async fn validate_event_model( Ok((id, resp.issues)) } +/// What `ValidateProject` covers: every model in a namespace, or those a +/// Domain or Project claims. Strategic findings (BoundedContext, Domain, +/// Subdomain, Project rules) come back once on the report with no +/// `event_model_id`, so a scope with no EventModel still reports them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectScope { + Namespace(String), + Domain(EntityAddress), + Project(EntityAddress), +} + +impl ProjectScope { + /// Scope for a Domain, parsed from `/[@version]`. + pub fn domain(s: &str) -> Result { + parse_scope_address(pb::EntityKind::Domain, s).map(Self::Domain) + } + + /// Scope for a Project, parsed from `/[@version]`. + pub fn project(s: &str) -> Result { + parse_scope_address(pb::EntityKind::Project, s).map(Self::Project) + } + + /// Scope for a namespace; the name must be a valid id component. + pub fn namespace(s: &str) -> Result { + trogon_atlas_core::validate_id_component(s) + .map_err(|e| crate::failure::invalid_input(format!("namespace {e}")))?; + Ok(Self::Namespace(s.to_string())) + } +} + +fn parse_scope_address(kind: pb::EntityKind, s: &str) -> Result { + if s.matches('/').count() != 1 { + crate::invalid_input!("expected /[@version], got {s:?}"); + } + let address = view::parse_address(&format!("{}/{s}", pb::canonical::kind_short(kind)))?; + Ok(address) +} + +async fn resolve_id(client: &mut Client, address: &EntityAddress) -> Result { + if let Some(version) = address.version { + return Ok(pb::Id { + namespace: address.namespace.clone(), + slug: address.slug.clone(), + version, + }); + } + let entity = view::fetch_entity(client, address).await?; + trogon_atlas_core::entity_id(&entity) + .cloned() + .ok_or_else(|| anyhow::anyhow!("stored entity has no id")) +} + +/// Validate every model in `scope`. Both the latest-version lookup and the +/// RPC carry the client's branch header, so a branch-scoped client validates +/// the branch's view. +pub async fn validate_project( + client: &mut Client, + scope: &ProjectScope, +) -> Result { + let scope = match scope { + ProjectScope::Namespace(namespace) => { + pb::validate_project_request::Scope::Namespace(namespace.clone()) + } + ProjectScope::Domain(address) => { + pb::validate_project_request::Scope::DomainId(resolve_id(client, address).await?) + } + ProjectScope::Project(address) => { + pb::validate_project_request::Scope::ProjectId(resolve_id(client, address).await?) + } + }; + Ok(client + .validate_project(pb::ValidateProjectRequest { scope: Some(scope) }) + .await + .context("ValidateProject failed")? + .into_inner()) +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -82,4 +159,42 @@ mod tests { assert!(parse_model_address(bad).is_err(), "{bad} should not parse"); } } + + #[test] + fn project_scopes_parse_namespace_slug_and_optional_version() { + let ProjectScope::Domain(domain) = ProjectScope::domain("shop/shop").unwrap() else { + panic!("expected a domain scope"); + }; + assert_eq!(domain.kind, pb::EntityKind::Domain); + assert_eq!(domain.version, None); + let ProjectScope::Project(project) = ProjectScope::project("shop/main@2").unwrap() else { + panic!("expected a project scope"); + }; + assert_eq!(project.kind, pb::EntityKind::Project); + assert_eq!( + ( + project.namespace.as_str(), + project.slug.as_str(), + project.version + ), + ("shop", "main", Some(2)) + ); + assert_eq!( + ProjectScope::namespace("shop").unwrap(), + ProjectScope::Namespace("shop".into()) + ); + } + + #[test] + fn project_scopes_reject_malformed_input() { + for bad in ["shop", "domain/shop/shop", "shop/", "shop/shop@0"] { + assert!(ProjectScope::domain(bad).is_err(), "{bad} should not parse"); + assert!( + ProjectScope::project(bad).is_err(), + "{bad} should not parse" + ); + } + assert!(ProjectScope::namespace("").is_err()); + assert!(ProjectScope::namespace("a/b").is_err()); + } } diff --git a/rsworkspace/crates/trogon-atlas-mcp/README.md b/rsworkspace/crates/trogon-atlas-mcp/README.md index 4c60f8e7..22ea8329 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/README.md +++ b/rsworkspace/crates/trogon-atlas-mcp/README.md @@ -52,6 +52,48 @@ the write landed in, because a branch write keeps no durable receipt: `scope` is `baseline` and `branch` is `null` when no branch applies. +`put_entity`, `put_entity_json`, `delete_entity`, `batch_mutate` and +`batch_mutate_json` also return the server's validation findings as a JSON +`issues` array next to `binpb_base64`, so an agent reads them without decoding +the protobuf. Findings +are non-blocking, strategic ones included (BoundedContext, Domain and Subdomain +rules), and `validate_only` dry runs return them too: + +```json +{ + "scope": "baseline", + "branch": null, + "issues": [ + { + "severity": "warning", + "code": "DOMAIN_IN_CONTEXT_NAMESPACE", + "message": "...", + "subject": "domain:shop/shop@1", + "field": null + } + ], + "binpb_base64": "..." +} +``` + +`severity` is `error`, `warning` or `info`; `subject` and `field` are `null` +when the finding names no entity or path. Batch findings also carry +`op_index` (zero-based position of the put or delete op) and `op_entity` (the +entity that op wrote or deleted). `delete_entity` returns what the delete leaves +behind, such as `REALIZES_DANGLING` after force-deleting a realized Subdomain, +and `mode: dry_run` previews it. `merge_branch` adds the same `issues` array from the response's +post-merge validation. Fix every warning and error that comes back. + +`apply_manifests` returns the same objects in `issues`, so one shape covers +every mutating tool. `op_index` is the position among the changed manifests +sent in the batch (unchanged manifests are skipped) and `op_entity` is the +manifest's entity id. + +Strategic findings also appear once on the store-level report of +`validate_project` (an empty `event_model_id`). After editing only Project, +Domain, Subdomain or BoundedContext entities, run `validate_project` with the +namespace or project scope, since there may be no EventModel to validate. + ## Error contract Internal helpers (`descriptor_for`, `message_from_json`, diff --git a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs index de0d2224..1546b4c6 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs @@ -143,8 +143,18 @@ pub const SERVER_INSTRUCTIONS: &str = "trogon-atlas MCP: tools for the trogonatl in a namespace named after the domain, which is NOT a bounded context. A \ Subdomain never contains contexts: BoundedContext.realizes maps solution to \ problem (cross-namespace, legal: knowledge, not coupling). One context \ - realizing several subdomains is a monolith confessing. Classification goes \ - on the SUBDOMAIN, never the context.\n\ + realizing several subdomains is a monolith confessing; realizing subdomains \ + of different classifications also warns (REALIZES_MIXED_CLASSIFICATIONS), \ + because one boundary then carries two investment levels. A Domain declared in \ + a context's namespace warns (DOMAIN_IN_CONTEXT_NAMESPACE): give it its own \ + problem-space namespace. Classification goes on the SUBDOMAIN, never the \ + context. Layout: the Project, the Domain and ALL Subdomains live in ONE \ + problem-space namespace (Domain and Project slug == that namespace); each \ + context namespace holds only its BoundedContext, which realizes ONE \ + subdomain across namespaces. Never create a Project or Domain per context \ + namespace to silence DOMAIN_MISSING_PROJECT; point the contexts at the one \ + problem space instead. Declare a relationship intent (rule 16) for every \ + cross-context seam.\n\ 16. Seams carry data; relationships carry intent. The DOWNSTREAM \ BoundedContext declares relationships: upstream context + intent \ (CUSTOMER_SUPPLIER / CONFORMIST / PARTNERSHIP / SEPARATE_WAYS) + doc. Intent \ @@ -196,7 +206,12 @@ pub const SERVER_INSTRUCTIONS: &str = "trogon-atlas MCP: tools for the trogonatl entity.system (uid, created_at) is SERVER-OWNED incarnation identity, Kubernetes \ metadata.uid style: assigned on create, preserved across updates, regenerated on \ delete+recreate. Anything you send there is ignored; never try to set it.\n\ - Run validate_event_model after every mutation batch and fix all Errors. Mutations \ + Mutation responses (put_entity, put_entity_json, batch_mutate, batch_mutate_json, merge_branch) \ + return non-blocking validation findings, strategic ones included, in an `issues` array \ + beside binpb_base64: read it and fix them. Run validate_event_model after every mutation \ + batch and fix all Errors and Warnings. After editing only problem-space entities \ + (Project, Domain, Subdomain) or a BoundedContext there may be no EventModel to validate: \ + run validate_project with the namespace or project scope instead. Mutations \ execute concurrently when pipelined: issue ordered workflows (write -> delete -> \ validate) as separate sequential requests."; @@ -698,6 +713,25 @@ impl McpServer { CallToolResult::success(vec![ContentBlock::text(body)]) } + /// `scoped_envelope` plus the response's validation findings as a JSON + /// `issues` array, so an agent can read non-blocking findings without + /// decoding the binpb. + fn scoped_envelope_with_issues( + &self, + msg: &M, + issues: &[serde_json::Value], + ) -> CallToolResult { + let (scope, branch) = self.scope_fields(); + let body = serde_json::json!({ + "scope": scope, + "branch": branch, + "issues": issues, + "binpb_base64": encode_b64(msg), + }) + .to_string(); + CallToolResult::success(vec![ContentBlock::text(body)]) + } + fn scoped_response( &self, message_name: &str, @@ -986,6 +1020,114 @@ fn ok_envelope(msg: &M) -> CallToolResult { CallToolResult::success(vec![ContentBlock::text(body)]) } +/// JSON shape of one `ValidationIssue` in a mutation response: +/// `{severity, code, subject, field, message}`. `severity` is +/// `error`/`warning`/`info`; `subject` is the canonical entity id string or +/// `null`; `field` is the structural path inside the subject or `null`. +fn issue_json(issue: &pb::ValidationIssue) -> serde_json::Value { + let mut value = trogon_atlas_client::apply::issue_to_json(issue); + if let serde_json::Value::Object(fields) = &mut value { + fields.insert( + "field".into(), + (!issue.subject_field.is_empty()) + .then_some(issue.subject_field.as_str()) + .into(), + ); + } + value +} + +fn issues_json(issues: &[pb::ValidationIssue]) -> Vec { + issues.iter().map(issue_json).collect() +} + +/// Canonical id of the entity each batch op writes or deletes, in op order. +/// Read from the request because a delete result names no entity. +fn batch_op_entities(ops: &[pb::BatchMutateOp]) -> Vec> { + ops.iter() + .map(|op| match op.op.as_ref()? { + pb::batch_mutate_op::Op::Put(put) => { + let entity = put.entity.as_ref()?; + let kind = trogon_atlas_core::entity_kind(entity)?; + let id = trogon_atlas_core::entity_id(entity)?; + Some(pb::canonical::id_string(kind, id)) + } + pb::batch_mutate_op::Op::Delete(delete) => { + let kind = pb::EntityKind::try_from(delete.kind).ok()?; + Some(pb::canonical::id_string(kind, delete.id.as_ref()?)) + } + }) + .collect() +} + +/// Findings of every put and delete op in a batch, in op order. Each carries +/// the zero based `op_index` and the canonical `op_entity` id of the entity +/// the op wrote or deleted, because a finding's own `subject` can be a +/// different entity (a strategic rule attached to the BoundedContext, Domain +/// or Subdomain left behind). +fn batch_issues_json( + op_entities: &[Option], + resp: &pb::BatchMutateResponse, +) -> Vec { + let mut out = Vec::new(); + for (index, result) in resp.results.iter().enumerate() { + let findings = match result.result.as_ref() { + Some(pb::batch_mutate_op_result::Result::Put(put)) => &put.validation, + Some(pb::batch_mutate_op_result::Result::Delete(delete)) => &delete.validation, + None => continue, + }; + let op_entity = op_entities.get(index).and_then(Option::as_deref); + out.extend(op_tagged_issues_json(index, op_entity, findings)); + } + out +} + +/// `issues_json` with each finding tagged by the op that produced it. +fn op_tagged_issues_json( + op_index: usize, + op_entity: Option<&str>, + issues: &[pb::ValidationIssue], +) -> Vec { + issues + .iter() + .map(|issue| { + let mut value = issue_json(issue); + if let serde_json::Value::Object(fields) = &mut value { + fields.insert("op_index".into(), op_index.into()); + fields.insert("op_entity".into(), op_entity.into()); + } + value + }) + .collect() +} + +/// Findings of an `apply_manifests` run, in the same shape as batch findings. +/// `op_index` counts the changed manifests actually sent, and `op_entity` is +/// the manifest's canonical id. +fn apply_issues_json(op_issues: &[trogon_atlas_client::apply::OpIssues]) -> Vec { + op_issues + .iter() + .flat_map(|op| op_tagged_issues_json(op.op_index, Some(op.op_entity.as_str()), &op.issues)) + .collect() +} + +/// Add `issues` to an already-built JSON tool result body so a response that +/// carries findings alongside other data exposes them without a decode step. +fn with_issues(mut result: CallToolResult, issues: Vec) -> CallToolResult { + let body = result + .content + .first() + .and_then(|block| block.as_text()) + .and_then(|text| serde_json::from_str::(&text.text).ok()); + if let Some(serde_json::Value::Object(mut fields)) = body { + fields.insert("issues".into(), issues.into()); + result.content = vec![ContentBlock::text( + serde_json::Value::Object(fields).to_string(), + )]; + } + result +} + /// `BatchMutateResponse.status == STATUS_FAILED` is an RPC that returned /// `Ok` while carrying a domain failure in its body; `ok_envelope` alone /// would report it to the agent as a successful tool call. Classify it the @@ -2006,7 +2148,7 @@ impl McpServer { } #[tool( - description = "Create or replace an entity. The entity payload is a base64-encoded prost binpb of an Entity message (obtain via get_entity, edit binpb_base64, and pass back). A failed write is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope.", + description = "Create or replace an entity. The entity payload is a base64-encoded prost binpb of an Entity message (obtain via get_entity, edit binpb_base64, and pass back). A failed write is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64: fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = false, @@ -2047,7 +2189,7 @@ impl McpServer { .map_err(tool_err("put_entity")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); - Ok(self.scoped_envelope(&resp)) + Ok(self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation))) } #[tool( @@ -2090,7 +2232,7 @@ impl McpServer { } #[tool( - description = "Create or replace an entity from proto3 JSON (no protobuf tooling required). entity_json is an Entity message, e.g. {\"event\": {\"id\": {\"namespace\": \"shop\", \"slug\": \"order-placed\", \"version\": 1}, \"title\": \"Order placed\"}}.", + description = "Create or replace an entity from proto3 JSON (no protobuf tooling required). entity_json is an Entity message, e.g. {\"event\": {\"id\": {\"namespace\": \"shop\", \"slug\": \"order-placed\", \"version\": 1}, \"title\": \"Order placed\"}}. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64: fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = false, @@ -2134,11 +2276,11 @@ impl McpServer { .map_err(tool_err("put_entity_json")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); - Ok(self.scoped_envelope(&resp)) + Ok(self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation))) } #[tool( - description = "Atomic batch of put/delete operations expressed as a proto3 JSON BatchMutateRequest (no protobuf tooling required). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it.", + description = "Atomic batch of put/delete operations expressed as a proto3 JSON BatchMutateRequest (no protobuf tooling required). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64, each tagged with op_index and op_entity of the put or delete op that produced it (validate_only dry runs included): fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = true, @@ -2171,6 +2313,7 @@ impl McpServer { .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; self.ensure_can_mutate(&mut client, req.validate_only) .await?; + let op_entities = batch_op_entities(&req.ops); let resp = client .batch_mutate(req) .await @@ -2179,11 +2322,11 @@ impl McpServer { if resp.status == pb::batch_mutate_response::Status::Failed as i32 { return Err(McpError::from(batch_mutate_failure(&resp)).into()); } - Ok(self.scoped_envelope(&resp)) + Ok(self.scoped_envelope_with_issues(&resp, &batch_issues_json(&op_entities, &resp))) } #[tool( - description = "Delete an entity. mode = 'fail_if_referenced' (default), 'force', or 'dry_run'. A refused delete is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope.", + description = "Delete an entity. mode = 'fail_if_referenced' (default), 'force', or 'dry_run'. A refused delete is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope. Non-blocking strategic findings the delete leaves behind (for example REALIZES_DANGLING on a BoundedContext whose Subdomain is gone, SUBDOMAIN_UNREALIZED) come back in the `issues` array next to binpb_base64; a dry_run previews them. Fix every warning and error.", annotations( read_only_hint = false, destructive_hint = true, @@ -2214,11 +2357,11 @@ impl McpServer { .map_err(tool_err("delete_entity")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); - Ok(self.scoped_envelope(&resp)) + Ok(self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation))) } #[tool( - description = "Atomic batch of put/delete operations. Each op specifies op='put' (with entity_b64) or op='delete' (with kind/namespace/slug/version). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it.", + description = "Atomic batch of put/delete operations. Each op specifies op='put' (with entity_b64) or op='delete' (with kind/namespace/slug/version). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64, each tagged with op_index and op_entity of the put or delete op that produced it (validate_only dry runs included): fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = true, @@ -2332,6 +2475,7 @@ impl McpServer { let mut client = self.client(); self.ensure_can_mutate(&mut client, req.validate_only) .await?; + let op_entities = batch_op_entities(&req.ops); let resp = client .batch_mutate(req) .await @@ -2342,7 +2486,7 @@ impl McpServer { let err: rmcp::ErrorData = McpError::from(batch_mutate_failure(&resp)).into(); return Err(attach_operation_id(err, &operation_id)); } - Ok(self.scoped_envelope(&resp)) + Ok(self.scoped_envelope_with_issues(&resp, &batch_issues_json(&op_entities, &resp))) } #[tool( @@ -2489,7 +2633,7 @@ impl McpServer { } #[tool( - description = "Run structural validation over a stored EventModel and its members.", + description = "Run full validation over a stored EventModel: structural findings over the model and its members, plus the strategic chain of the model's namespace (its BoundedContext, the Subdomains it realizes, their Domains, and any Domain declared in the namespace). Fix Errors and Warnings.", annotations( read_only_hint = true, destructive_hint = false, @@ -2545,7 +2689,7 @@ impl McpServer { } #[tool( - description = "Validate every EventModel in a project, domain or namespace in one call instead of N. Returns per-model issue lists plus aggregate Error/Warning/Info counts. Scope by project_namespace+project_slug, by domain_namespace+domain_slug, OR by namespace -- set exactly one.", + description = "Validate every EventModel in a project, domain or namespace in one call instead of N. Returns per-model issue lists plus aggregate Error/Warning/Info counts. Strategic findings (BoundedContext, Domain, Subdomain, Project rules) appear once, on the store-level report whose event_model_id is empty, not on any model report. A namespace, project or domain scope also covers problem-space namespaces that hold no EventModel, so use it after editing only Project/Domain/Subdomain or BoundedContext entities. Scope by project_namespace+project_slug, by domain_namespace+domain_slug, OR by namespace -- set exactly one.", annotations( read_only_hint = true, destructive_hint = false, @@ -2809,7 +2953,7 @@ impl McpServer { } #[tool( - description = "Apply one or more YAML manifests (same format as trogon-atlas apply): create missing entities, update changed ones, skip unchanged ones, as one atomic batch. Set dry_run to validate without persisting. Each write is bound to the revision it was planned against: if another writer changed or created an entity in between, nothing is written and the call fails with data.category \"stale_state\" and data.next_action \"replan\", naming the entity with expected_etag and actual_etag (null means absent); call again to plan from fresh state.", + description = "Apply one or more YAML manifests (same format as trogon-atlas apply): create missing entities, update changed ones, skip unchanged ones, as one atomic batch. Set dry_run to validate without persisting. Non-blocking validation findings, strategic ones included, come back in the `issues` array as {severity, code, subject, field, message, op_index, op_entity} (op_index is the position among the changed manifests sent, op_entity the manifest's entity id): fix every warning and error. Each write is bound to the revision it was planned against: if another writer changed or created an entity in between, nothing is written and the call fails with data.category \"stale_state\" and data.next_action \"replan\", naming the entity with expected_etag and actual_etag (null means absent); call again to plan from fresh state.", annotations( read_only_hint = false, destructive_hint = false, @@ -2849,7 +2993,7 @@ impl McpServer { "scope": scope, "branch": branch, "lines": outcome.lines, - "issues": outcome.issues.iter().map(trogon_atlas_client::apply::format_issue).collect::>(), + "issues": apply_issues_json(&outcome.op_issues), "operation_id": (!operation_id.is_empty()).then_some(operation_id.as_str()), }) .to_string(); @@ -3042,7 +3186,7 @@ impl McpServer { } #[tool( - description = "Merge a branch onto baseline (Phase 2: Review and merge). All-or-nothing: any conflict (see conflicts) or post-merge validation Error (see validation) persists nothing. Set dry_run to run full detection and validation without persisting even on success; the result splits the landable entries into added_count, changed_count and deleted_count and lists the post-merge findings in validation. keep_branch controls whether a successful, non-dry-run merge deletes the branch afterward (default: deletes it). Set auto_merge to land the combined result for edit/edit conflicts whose two sides changed different fields.", + description = "Merge a branch onto baseline (Phase 2: Review and merge). All-or-nothing: any conflict (see conflicts) or post-merge validation Error (see validation) persists nothing. Set dry_run to run full detection and validation without persisting even on success; the result splits the landable entries into added_count, changed_count and deleted_count and lists the post-merge findings, strategic ones included, in validation and, as JSON, in `issues` (Warning and Info findings are non-blocking; fix them). keep_branch controls whether a successful, non-dry-run merge deletes the branch afterward (default: deletes it). Set auto_merge to land the combined result for edit/edit conflicts whose two sides changed different fields.", annotations( read_only_hint = false, destructive_hint = true, @@ -3069,10 +3213,13 @@ impl McpServer { .map_err(tool_err("merge_branch")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); - Ok(ok_response( - "trogonatlas.api.eventmodel.v1alpha1.MergeBranchResponse", - &resp, - p.json.unwrap_or(false), + Ok(with_issues( + ok_response( + "trogonatlas.api.eventmodel.v1alpha1.MergeBranchResponse", + &resp, + p.json.unwrap_or(false), + ), + issues_json(&resp.validation), )) } @@ -3458,6 +3605,139 @@ mod tests { ); } + #[test] + fn server_instructions_cover_strategic_layout_and_validation_followup() { + for needle in [ + "ONE problem-space namespace", + "DOMAIN_MISSING_PROJECT", + "validate_project with the namespace or project scope", + "`issues` array", + "Errors and Warnings", + ] { + assert!( + SERVER_INSTRUCTIONS.contains(needle), + "doctrine lost: {needle}" + ); + } + } + + fn strategic_issue() -> pb::ValidationIssue { + pb::ValidationIssue { + severity: pb::validation_issue::Severity::Warning as i32, + code: "DOMAIN_IN_CONTEXT_NAMESPACE".into(), + message: "domain lives in a context namespace".into(), + subject: Some(pb::EntityRef { + kind: pb::EntityKind::Domain as i32, + id: Some(id_from("shop", "shop", 1)), + }), + subject_field: "id.namespace".into(), + ..Default::default() + } + } + + #[test] + fn issue_json_carries_severity_code_subject_field_and_message() { + let value = issue_json(&strategic_issue()); + assert_eq!(value["severity"], "warning"); + assert_eq!(value["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); + assert_eq!(value["message"], "domain lives in a context namespace"); + assert_eq!(value["field"], "id.namespace"); + assert!( + value["subject"].as_str().unwrap().contains("shop"), + "{value}" + ); + let bare = issue_json(&pb::ValidationIssue::default()); + assert!(bare["field"].is_null(), "{bare}"); + assert!(bare["subject"].is_null(), "{bare}"); + } + + #[test] + fn batch_issues_json_tags_each_finding_with_its_op_index_and_entity() { + let put = |issues: Vec| pb::BatchMutateOpResult { + result: Some(pb::batch_mutate_op_result::Result::Put( + pb::PutEntityResponse { + validation: issues, + ..Default::default() + }, + )), + }; + let domain_put = |slug: &str| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(pb::Entity { + system: None, + kind: Some(pb::entity::Kind::Domain(pb::Domain { + id: Some(id_from("shop", slug, 1)), + ..Default::default() + })), + }), + ..Default::default() + })), + }; + let delete_op = pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: pb::EntityKind::Subdomain as i32, + id: Some(id_from("shop", "gone", 1)), + ..Default::default() + })), + }; + let entities = batch_op_entities(&[ + domain_put("clean"), + delete_op.clone(), + domain_put("noisy"), + delete_op, + ]); + let resp = pb::BatchMutateResponse { + results: vec![ + put(vec![]), + pb::BatchMutateOpResult { + result: Some(pb::batch_mutate_op_result::Result::Delete( + pb::DeleteEntityResponse { + validation: vec![strategic_issue()], + ..Default::default() + }, + )), + }, + put(vec![strategic_issue()]), + pb::BatchMutateOpResult { + result: Some(pb::batch_mutate_op_result::Result::Delete( + pb::DeleteEntityResponse::default(), + )), + }, + ], + ..Default::default() + }; + let issues = batch_issues_json(&entities, &resp); + assert_eq!(issues.len(), 2, "{issues:?}"); + assert_eq!(issues[0]["op_index"], 1); + assert!( + issues[0]["op_entity"].as_str().unwrap().contains("gone"), + "{:?}", + issues[0] + ); + assert_eq!(issues[1]["op_index"], 2); + assert!( + issues[1]["op_entity"].as_str().unwrap().contains("noisy"), + "{:?}", + issues[1] + ); + assert_eq!(issues[1]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); + } + + #[test] + fn with_issues_adds_the_array_and_keeps_the_rest_of_the_body() { + let base = ok_response( + "trogonatlas.api.eventmodel.v1alpha1.MergeBranchResponse", + &pb::MergeBranchResponse::default(), + false, + ); + let result = with_issues(base, issues_json(&[strategic_issue()])); + let text = result.content[0].as_text().unwrap().text.clone(); + let body: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(body["format"], "binpb_base64"); + assert!(body["binpb_base64"].is_string(), "{body}"); + assert_eq!(body["issues"][0]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); + } + #[test] fn kinds_to_i32_translates_and_short_circuits_on_first_unknown() { let ok = kinds_to_i32(Some(&vec!["event".into(), "command".into()])).unwrap(); @@ -3705,4 +3985,21 @@ mod tests { ); } } + + #[test] + fn apply_issues_json_uses_the_batch_issue_shape_with_op_tags() { + let op_issues = vec![trogon_atlas_client::apply::OpIssues { + op_index: 1, + op_entity: "domain:shop/shop@1".into(), + issues: vec![strategic_issue()], + }]; + let issues = apply_issues_json(&op_issues); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); + assert_eq!(issues[0]["severity"], "warning"); + assert_eq!(issues[0]["field"], "id.namespace"); + assert_eq!(issues[0]["op_index"], 1); + assert_eq!(issues[0]["op_entity"], "domain:shop/shop@1"); + assert_eq!(apply_issues_json(&[]), Vec::::new()); + } } diff --git a/rsworkspace/crates/trogon-atlas-mcp/tests/tool_lifecycle_integration.rs b/rsworkspace/crates/trogon-atlas-mcp/tests/tool_lifecycle_integration.rs index 217b58a2..953146d4 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/tests/tool_lifecycle_integration.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/tests/tool_lifecycle_integration.rs @@ -130,6 +130,7 @@ async fn mcp_read_write_tool_lifecycle() { assert!(!put_result.is_error.unwrap_or(false), "{put_result:?}"); let put_body = call_tool_result_text(&put_result); assert!(put_body["binpb_base64"].is_string(), "{put_body:?}"); + assert!(put_body["issues"].is_array(), "{put_body:?}"); // get_entity_json: read back as proto3 JSON, with an etag. let get_json_result = client @@ -216,6 +217,7 @@ async fn mcp_read_write_tool_lifecycle() { let raw = base64_decode(batch_body["binpb_base64"].as_str().unwrap()); let decoded = pb::BatchMutateResponse::decode(raw.as_slice()).unwrap(); assert_eq!(decoded.results.len(), 2, "{decoded:?}"); + assert!(batch_body["issues"].is_array(), "{batch_body:?}"); // delete_entity: the second entity is now deletable directly. let delete_result = client @@ -337,6 +339,7 @@ spec: { title: Order placed } let dry_body = call_tool_result_text(&dry_result); let lines = dry_body["lines"].as_array().unwrap(); assert_eq!(lines.len(), 1, "{dry_body:?}"); + assert!(dry_body["issues"].is_array(), "{dry_body:?}"); assert!( lines[0].as_str().unwrap().contains("dry run"), "{:?}", @@ -539,3 +542,202 @@ async fn mcp_batch_mutate_json_status_failed_reports_as_error() { mcp_service.cancel().await.unwrap(); } + +// Strategic findings are non-blocking: put_entity_json lands the write and +// returns them in `issues`, readable without decoding binpb_base64. Depends on +// the server returning strategic findings from PutEntity. +#[tokio::test(flavor = "multi_thread")] +async fn mcp_put_entity_json_returns_strategic_findings_in_issues() { + let (client, mcp_service, _grpc_endpoint) = start_mcp_client().await; + let ns = "mcp-strategic-issues"; + + let put = |entity: serde_json::Value| { + let client = &client; + async move { + client + .call_tool( + rmcp::model::CallToolRequestParams::new("put_entity_json").with_arguments( + args_obj(&[("entity_json", serde_json::json!(entity.to_string()))]), + ), + ) + .await + .unwrap() + } + }; + let context = put(serde_json::json!({ + "boundedContext": {"id": {"namespace": ns, "slug": ns, "version": 1}, "title": "Shop"} + })) + .await; + assert!(!context.is_error.unwrap_or(false), "{context:?}"); + + let domain = put(serde_json::json!({ + "domain": {"id": {"namespace": ns, "slug": ns, "version": 1}, "title": "Shop"} + })) + .await; + assert!(!domain.is_error.unwrap_or(false), "{domain:?}"); + let body = call_tool_result_text(&domain); + let issue = body["issues"] + .as_array() + .unwrap() + .iter() + .find(|issue| issue["code"] == "DOMAIN_IN_CONTEXT_NAMESPACE") + .unwrap_or_else(|| panic!("no DOMAIN_IN_CONTEXT_NAMESPACE in {body:?}")); + assert_eq!(issue["severity"], "warning", "{issue:?}"); + assert!(issue["subject"].as_str().unwrap().contains(ns), "{issue:?}"); + assert!(body["binpb_base64"].is_string(), "{body:?}"); + + mcp_service.cancel().await.unwrap(); +} + +// delete_entity returns the findings a delete leaves behind in `issues`: a +// force delete of a realized Subdomain strands the BoundedContext that +// realizes it. A dry_run previews the same finding without deleting. Depends +// on the server returning strategic findings from DeleteEntity. +#[tokio::test(flavor = "multi_thread")] +async fn mcp_delete_entity_returns_strategic_findings_in_issues() { + let (client, mcp_service, _grpc_endpoint) = start_mcp_client().await; + let ps = "mcp-del-ps"; + let ctx = "mcp-del"; + + let put = |entity: serde_json::Value| { + let client = &client; + async move { + let result = client + .call_tool( + rmcp::model::CallToolRequestParams::new("put_entity_json").with_arguments( + args_obj(&[("entity_json", serde_json::json!(entity.to_string()))]), + ), + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false), "{result:?}"); + } + }; + put(serde_json::json!({ + "domain": {"id": {"namespace": ps, "slug": ps, "version": 1}, "title": "Shop"} + })) + .await; + put(serde_json::json!({ + "subdomain": { + "id": {"namespace": ps, "slug": "ordering", "version": 1}, + "title": "Ordering", + "domain": {"id": {"namespace": ps, "slug": ps, "version": 1}}, + "classification": "CLASSIFICATION_CORE" + } + })) + .await; + put(serde_json::json!({ + "boundedContext": { + "id": {"namespace": ctx, "slug": ctx, "version": 1}, + "title": "Ordering", + "realizes": [{"id": {"namespace": ps, "slug": "ordering", "version": 1}}] + } + })) + .await; + + let delete = |mode: &'static str| { + let client = &client; + async move { + let result = client + .call_tool( + rmcp::model::CallToolRequestParams::new("delete_entity").with_arguments( + args_obj(&[ + ("kind", serde_json::json!("subdomain")), + ("namespace", serde_json::json!(ps)), + ("slug", serde_json::json!("ordering")), + ("version", serde_json::json!(1)), + ("mode", serde_json::json!(mode)), + ]), + ), + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false), "{result:?}"); + call_tool_result_text(&result) + } + }; + + let preview = delete("dry_run").await; + assert!( + preview["issues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["code"] == "REALIZES_DANGLING"), + "dry_run lost the preview findings: {preview:?}" + ); + + let body = delete("force").await; + let issue = body["issues"] + .as_array() + .unwrap() + .iter() + .find(|issue| issue["code"] == "REALIZES_DANGLING") + .unwrap_or_else(|| panic!("no REALIZES_DANGLING in {body:?}")); + assert!(issue["severity"].is_string(), "{issue:?}"); + assert!( + issue["subject"].as_str().unwrap().contains(ctx), + "{issue:?}" + ); + assert!(body["binpb_base64"].is_string(), "{body:?}"); + + mcp_service.cancel().await.unwrap(); +} + +// apply_manifests returns its findings as the same objects put/batch return, +// tagged with the op that produced them. Depends on the server returning +// strategic findings from BatchMutate. +#[tokio::test(flavor = "multi_thread")] +async fn mcp_apply_manifests_dry_run_returns_strategic_findings_as_objects() { + let (client, mcp_service, _grpc_endpoint) = start_mcp_client().await; + let ns = "mcp-apply-strategic"; + + let context = client + .call_tool( + rmcp::model::CallToolRequestParams::new("put_entity_json").with_arguments(args_obj(&[( + "entity_json", + serde_json::json!(serde_json::json!({ + "boundedContext": {"id": {"namespace": ns, "slug": ns, "version": 1}, "title": "Shop"} + }) + .to_string()), + )])), + ) + .await + .unwrap(); + assert!(!context.is_error.unwrap_or(false), "{context:?}"); + + let manifest_yaml = format!( + r" +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Domain +metadata: {{ namespace: {ns}, name: {ns} }} +spec: {{ title: Shop }} +" + ); + let result = client + .call_tool( + rmcp::model::CallToolRequestParams::new("apply_manifests").with_arguments(args_obj(&[ + ("manifests_yaml", serde_json::json!(manifest_yaml)), + ("dry_run", serde_json::json!(true)), + ])), + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false), "{result:?}"); + let body = call_tool_result_text(&result); + let issue = body["issues"] + .as_array() + .unwrap() + .iter() + .find(|issue| issue["code"] == "DOMAIN_IN_CONTEXT_NAMESPACE") + .unwrap_or_else(|| panic!("no DOMAIN_IN_CONTEXT_NAMESPACE in {body:?}")); + assert!(issue.is_object(), "{issue:?}"); + assert_eq!(issue["severity"], "warning", "{issue:?}"); + assert_eq!(issue["op_index"], 0, "{issue:?}"); + assert!( + issue["op_entity"].as_str().unwrap().contains(ns), + "{issue:?}" + ); + + mcp_service.cancel().await.unwrap(); +} diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs index bd39592c..99033ccb 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs @@ -1531,6 +1531,15 @@ pub struct DeleteEntityResponse { /// Set only when the request carried an operation_id. #[prost(message, optional, tag="2")] pub operation_receipt: ::core::option::Option, + /// Strategic findings the delete causes on the entities left behind, for + /// example REALIZES_DANGLING on a bounded context whose subdomain is gone or + /// SUBDOMAIN_UNREALIZED on a subdomain whose only realizing context is gone. + /// Populated when the delete proceeds (including FORCE) and, for DRY_RUN, as + /// a preview of what the delete would leave. Advisory at every severity: they + /// never block the delete. Empty on an operation_id replay, which has no + /// current view to judge. + #[prost(message, repeated, tag="3")] + pub validation: ::prost::alloc::vec::Vec, } // ---------- Batch mutation (atomic multi-entity write) ----------------------- // @@ -2477,10 +2486,14 @@ pub struct MergeBranchResponse { pub status: i32, #[prost(message, repeated, tag="2")] pub conflicts: ::prost::alloc::vec::Vec, - /// Post-merge validation findings. STATUS_INVALID carries the findings that - /// blocked the merge. A dry run that reports STATUS_VALIDATED also carries - /// its Warning and Info findings, so a reviewer sees what the merge leaves - /// behind without a separate validation call. + /// Post-merge validation findings. Blocking errors (the store-level checks + /// of the post-merge world) produce STATUS_INVALID and carry the findings + /// that blocked the merge. Strategic findings about bounded contexts, + /// domains and subdomains, including those a landed deletion leaves behind, + /// ride along without blocking, at any severity, exactly as they do on + /// PutEntityResponse: STATUS_VALIDATED (a dry run) and STATUS_APPLIED carry + /// them, so a reviewer sees what the merge leaves behind without a separate + /// validation call. #[prost(message, repeated, tag="3")] pub validation: ::prost::alloc::vec::Vec, /// Number of non-CONVERGED entries actually landed on baseline, or that a @@ -2515,9 +2528,9 @@ pub mod merge_branch_response { /// At least one entry is a conflict class; nothing was persisted. /// See `conflicts`. Conflicts = 3, - /// No conflicts, but the post-merge world fails validation with at - /// least one Error-severity issue; nothing was persisted. See - /// `validation`. + /// No conflicts, but the post-merge world fails a blocking validation + /// check with at least one Error-severity issue; nothing was persisted. + /// See `validation`. Strategic findings never cause this status. Invalid = 4, } impl Status { diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 6a3634ae..88b6d31d 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -701,6 +701,28 @@ impl EventModelServiceImpl { self.narrow(Arc::new(result.entities), vis).await } + /// Strategic findings deleting `kind`/`id` would leave on the entities + /// that remain. Empty for kinds outside the problem space and context map. + async fn delete_strategic_findings( + &self, + kind: pb::EntityKind, + id: &pb::Id, + branch: Option<&str>, + vis: &Visibility, + ) -> Result, Status> { + if !is_strategic_kind(kind) { + return Ok(Vec::new()); + } + let before = self.snapshot_for(branch, vis).await?; + let after = world_after(&before, &[(kind, id, None)]); + Ok( + validation::strategic_issues_for_deletes(&[(kind, id.clone())], &before, &after, &[]) + .into_iter() + .flatten() + .collect(), + ) + } + /// The caller's view of baseline right after `changeset_id` landed. /// /// Every newer baseline changeset is undone from its revision rows. A @@ -2332,6 +2354,103 @@ fn sanitize_author_field(s: &str) -> String { .collect() } +/// Advisory strategic findings a merge leaves behind, at every severity: what +/// the landed contexts, domains and subdomains (and the entities they affect) +/// report in the post-merge world, plus what the landed deletions leave +/// dangling or unrealized. `pre_world` is the baseline before the merge. They +/// never block the merge; only the store-level checks do. +fn merge_strategic_findings( + op_metas: &[OpMetaLike], + pre_world: &[StoredEntity], + post_world: &[StoredEntity], +) -> Vec { + let changes: Vec = op_metas + .iter() + .filter(|m| m.is_put && is_strategic_kind(m.kind)) + .map(|m| (m.kind, m.id.clone())) + .collect(); + let mut findings: Vec = + validation::strategic_issues_by_change(&changes, post_world) + .into_iter() + .flatten() + .collect(); + let deletions: Vec = op_metas + .iter() + .filter(|m| !m.is_put && is_strategic_kind(m.kind)) + .map(|m| (m.kind, m.id.clone())) + .collect(); + if !deletions.is_empty() { + let before = world_after(pre_world, &put_changes(op_metas)); + findings.extend( + validation::strategic_issues_for_deletes(&deletions, &before, post_world, &findings) + .into_iter() + .flatten(), + ); + } + findings +} + +/// The puts of `metas` as world changes, in order. +fn put_changes(metas: &[OpMetaLike]) -> Vec<(pb::EntityKind, &pb::Id, Option<&pb::Entity>)> { + metas + .iter() + .filter(|m| m.is_put) + .map(|m| (m.kind, &m.id, m.entity.as_ref())) + .collect() +} + +/// The kinds whose write-time findings come from the strategic rules +/// (problem space and context map) rather than slice scenarios. +fn is_strategic_kind(kind: pb::EntityKind) -> bool { + matches!( + kind, + pb::EntityKind::BoundedContext | pb::EntityKind::Domain | pb::EntityKind::Subdomain + ) +} + +/// The store as it will be once `changes` landed: each put replaces the +/// stored entity with the same key (or joins the world), each `None` removes +/// it. Applied in order, so the last change to a key wins. +fn world_after( + base: &[StoredEntity], + changes: &[(pb::EntityKind, &pb::Id, Option<&pb::Entity>)], +) -> Vec { + let mut world: Vec> = base.iter().cloned().map(Some).collect(); + let mut index: std::collections::HashMap = base + .iter() + .enumerate() + .filter_map(|(i, se)| { + let kind = trogon_atlas_store::refs::entity_kind(&se.entity)?; + let id = trogon_atlas_store::refs::entity_id(&se.entity)?; + Some((pb::EntityKey::new(kind, id), i)) + }) + .collect(); + for (kind, id, entity) in changes { + let key = pb::EntityKey::new(*kind, id); + match (entity, index.get(&key).copied()) { + (Some(entity), Some(at)) => { + world[at] = Some(StoredEntity { + entity: (*entity).clone(), + etag: String::new(), + }); + } + (Some(entity), None) => { + index.insert(key, world.len()); + world.push(Some(StoredEntity { + entity: (*entity).clone(), + etag: String::new(), + })); + } + (None, Some(at)) => { + world[at] = None; + index.remove(&key); + } + (None, None) => {} + } + } + world.into_iter().flatten().collect() +} + fn is_slice_kind(kind: pb::EntityKind) -> bool { matches!( kind, @@ -2388,11 +2507,13 @@ fn build_op_results( } } else { let referrers = per_op_referrers.get(i).cloned().unwrap_or_default(); + let validation = per_op_validation.get(i).cloned().unwrap_or_default(); pb::BatchMutateOpResult { result: Some(pb::batch_mutate_op_result::Result::Delete( pb::DeleteEntityResponse { referrers, operation_receipt: None, + validation, }, )), } @@ -4329,7 +4450,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .check_schema(&*self.store, &entity, &[], branch.as_deref()) .await?; - let scenario_issues = if matches!( + let mut scenario_issues = if matches!( kind, pb::EntityKind::CommandSlice | pb::EntityKind::ReadModelSlice @@ -4348,6 +4469,19 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl &scenario_issues, ))); } + // Strategic findings never block: sequential puts of one problem + // space are order dependent, so a context put before its subdomains + // is transiently dangling and rejecting it would make the model + // impossible to build one call at a time. They ride the response. + if is_strategic_kind(kind) { + let all = self.snapshot_for(branch.as_deref(), &vis).await?; + let world = world_after(&all, &[(kind, &id, Some(&entity))]); + scenario_issues.extend( + validation::strategic_issues_by_change(&[(kind, id.clone())], &world) + .into_iter() + .flatten(), + ); + } if req.validate_only { self.validate_architecture_write(&entity, branch.as_deref(), &vis) @@ -4660,6 +4794,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl &req.operation_id, &record, )), + validation: Vec::new(), })); } } @@ -4734,9 +4869,13 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl pb::delete_entity_request::Mode::DryRun => { // operation_id and dry-run never coexist (rejected above), // so claim_key is always None here. + let validation = self + .delete_strategic_findings(kind, &id, branch.as_deref(), &vis) + .await?; return Ok(Response::new(pb::DeleteEntityResponse { referrers, operation_receipt: None, + validation, })); } pb::delete_entity_request::Mode::FailIfReferenced if is_referenced => { @@ -4764,6 +4903,18 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // From here, mode is FailIfReferenced-with-no-referrers or Force: // exactly one real write either way, settled the same way // regardless of which mode asked for it. + let validation = match self + .delete_strategic_findings(kind, &id, branch.as_deref(), &vis) + .await + { + Ok(findings) => findings, + Err(status) => { + if let Some(key) = &claim_key { + operation_receipts::settle_for_status(&*self.store, key, &status).await; + } + return Err(status); + } + }; let mut changeset = PendingChangeset::mint("DeleteEntity", &author, branch.as_deref()) .with_operation_id(claim_key.as_deref().map(|_| req.operation_id.as_str())) .with_operation_key(claim_key.as_deref()); @@ -4812,6 +4963,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl operation_receipt: claim_key.as_ref().map(|_| { operation_receipts::fresh_receipt(&req.operation_id, &receipt_changeset_id) }), + validation, })) } @@ -4868,6 +5020,8 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl let mut metas: Vec = Vec::with_capacity(req.ops.len()); let mut dry_run = req.validate_only; let mut has_slice_puts = false; + let mut has_strategic_puts = false; + let mut has_strategic_deletes = false; for (i, op) in req.ops.iter().enumerate() { match &op.op { @@ -4884,6 +5038,9 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl if is_slice_kind(kind) { has_slice_puts = true; } + if is_strategic_kind(kind) { + has_strategic_puts = true; + } if p.validate_only { dry_run = true; } @@ -4921,6 +5078,9 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl if matches!(mode, pb::delete_entity_request::Mode::DryRun) { dry_run = true; } + if is_strategic_kind(kind) { + has_strategic_deletes = true; + } metas.push(OpMetaLike { kind, id: id.clone(), @@ -5033,8 +5193,65 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // // Per-op issues (warnings/info) are retained for PutEntityResponse // on success; errors fail the batch with the first offending index. + // + // Strategic findings (contexts, domains, subdomains) are advisory + // for every severity and judged against the same overlay with + // replacement semantics, so the order of ops inside a batch never + // matters. Each finding rides the op whose entity it is about, or + // the op that caused it when that entity is not in the batch. let mut per_op_validation: Vec> = std::iter::repeat_with(Vec::new).take(metas.len()).collect(); + if has_strategic_puts || has_strategic_deletes { + if branch.is_none() && !has_slice_puts { + self.invalidate_snapshot().await; + } + let base = self.snapshot_for(branch.as_deref(), &vis).await?; + let world_changes: Vec<(pb::EntityKind, &pb::Id, Option<&pb::Entity>)> = metas + .iter() + .map(|m| (m.kind, &m.id, m.entity.as_ref().filter(|_| m.is_put))) + .collect(); + let world = world_after(&base, &world_changes); + let strategic_ops: Vec = metas + .iter() + .enumerate() + .filter(|(_, m)| m.is_put && is_strategic_kind(m.kind)) + .map(|(i, _)| i) + .collect(); + let changes: Vec = strategic_ops + .iter() + .map(|i| (metas[*i].kind, metas[*i].id.clone())) + .collect(); + for (op_index, issues) in strategic_ops + .iter() + .zip(validation::strategic_issues_by_change(&changes, &world)) + { + per_op_validation[*op_index] = issues; + } + let delete_ops: Vec = metas + .iter() + .enumerate() + .filter(|(_, m)| !m.is_put && is_strategic_kind(m.kind)) + .map(|(i, _)| i) + .collect(); + if !delete_ops.is_empty() { + let deletions: Vec = delete_ops + .iter() + .map(|i| (metas[*i].kind, metas[*i].id.clone())) + .collect(); + let reported: Vec = + per_op_validation.iter().flatten().cloned().collect(); + let before = world_after(&base, &put_changes(&metas)); + for (op_index, issues) in + delete_ops + .iter() + .zip(validation::strategic_issues_for_deletes( + &deletions, &before, &world, &reported, + )) + { + per_op_validation[*op_index] = issues; + } + } + } if has_slice_puts { if branch.is_none() { self.invalidate_snapshot().await; @@ -6430,6 +6647,14 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl let (_subdomain_domain, bc_domain) = build_domain_maps(all.as_ref()); let mut reports: Vec = Vec::new(); + // Problem-space namespaces carry no event model, so the scope + // seeds the namespaces itself instead of waiting for a model to + // vouch for them: the requested namespace, or the namespaces of the + // scoped domains and of the contexts realizing their subdomains. + let mut scoped_namespaces: std::collections::BTreeSet = match &scope { + Scope::Namespace(ns) => std::iter::once(ns.clone()).collect(), + _ => strategic_scope_namespaces(all.as_ref(), &scoped_domains), + }; for se in all.iter() { let Some(pb::entity::Kind::EventModel(em)) = se.entity.kind.as_ref() else { continue; @@ -6444,13 +6669,30 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl if !in_scope { continue; } - let issues = validation::validate_model(em, all.as_ref()); + let issues = validation::validate_model_content(em, all.as_ref()); + scoped_namespaces.insert(id.namespace.clone()); reports.push(ModelReport { event_model_id: Some(id.clone()), issues, }); } + // The strategic chain belongs to the namespaces, not to any one + // model, so it is reported once beside the store-level issues + // instead of repeating in every model that shares a domain. + let chain_issues = validation::strategic_chain_issues( + scoped_namespaces.iter().map(String::as_str), + all.as_ref(), + ); + let already_reported: Vec<_> = store_issues.clone(); + store_issues.extend(chain_issues.into_iter().filter(|chain| { + !already_reported.iter().any(|seen| { + seen.code == chain.code + && seen.subject == chain.subject + && seen.subject_field == chain.subject_field + }) + })); + // Surface the store-level issues by attaching them to a // synthetic "store" report, keeping the response shape uniform // without inventing a new field. @@ -7245,7 +7487,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl } Err(error) => return Err(store_err(error)), }; - let validation_issues = validation::validate_store_hygiene(&post_world); + let mut validation_issues = validation::validate_store_hygiene(&post_world); let has_error = validation_issues .iter() .any(|i| i.severity == pb::validation_issue::Severity::Error as i32); @@ -7256,6 +7498,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl ..landed.into_response() })); } + validation_issues.extend(merge_strategic_findings(&op_metas, &pre_all, &post_world)); return Ok(Response::new(pb::MergeBranchResponse { status: pb::merge_branch_response::Status::Validated as i32, validation: validation_issues, @@ -7266,7 +7509,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // Real merge: land verbatim, then validate the *actual* post-merge // world. If invalid, nothing was persisted -- `land_branch_merge` // has not been called yet, so there is nothing to roll back. - { + let strategic_findings = { let pre_all = self.snapshot(&vis).await?; let mut projected: Vec = (*pre_all).clone(); let mut by_key: std::collections::HashMap = @@ -7328,7 +7571,8 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl ..landed.into_response() })); } - } + merge_strategic_findings(&op_metas, &pre_all, &projected) + }; // Claimed here, past every validation failure above (conflicts, // dry-run/real invalid post-merge world) and ahead of the @@ -7464,6 +7708,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl Ok(Response::new(pb::MergeBranchResponse { status: pb::merge_branch_response::Status::Applied as i32, + validation: strategic_findings, operation_receipt: claim_key .as_ref() .map(|_| operation_receipts::fresh_receipt(&req.operation_id, &merge_changeset_id)), @@ -8360,6 +8605,45 @@ type BcNamespaceDomainMap = std::collections::HashMap; /// - `bc_domain`: BC namespace -> (domain ns, slug) /// /// Used by `validate_project` and `list_entities_by_domain`. +/// Namespaces a domain scope reaches on the strategic chain: each domain's +/// own namespace and the namespace of every context realizing one of its +/// subdomains. +fn strategic_scope_namespaces( + all: &[StoredEntity], + domains: &std::collections::HashSet<(String, String)>, +) -> std::collections::BTreeSet { + let mut namespaces: std::collections::BTreeSet = + domains.iter().map(|(ns, _)| ns.clone()).collect(); + let subdomains: std::collections::HashSet<(&str, &str)> = all + .iter() + .filter_map(|se| match se.entity.kind.as_ref()? { + pb::entity::Kind::Subdomain(sd) => { + let did = sd.domain.as_ref()?.id.as_ref()?; + let id = sd.id.as_ref()?; + domains + .contains(&(did.namespace.clone(), did.slug.clone())) + .then_some((id.namespace.as_str(), id.slug.as_str())) + } + _ => None, + }) + .collect(); + for se in all { + let Some(pb::entity::Kind::BoundedContext(bc)) = se.entity.kind.as_ref() else { + continue; + }; + let Some(id) = bc.id.as_ref() else { continue }; + if bc + .realizes + .iter() + .filter_map(|r| r.id.as_ref()) + .any(|rid| subdomains.contains(&(rid.namespace.as_str(), rid.slug.as_str()))) + { + namespaces.insert(id.namespace.clone()); + } + } + namespaces +} + fn build_domain_maps( all: &[trogon_atlas_store::StoredEntity], ) -> (SubdomainDomainMap, BcNamespaceDomainMap) { @@ -8430,9 +8714,9 @@ fn check_project_namespace_conflict(entity: &pb::Entity) -> Result<(), Status> { /// Reject a BoundedContext whose slug differs from its namespace. /// -/// The put path runs `validate_scenarios` only for slice kinds, so the -/// `CONTEXT_SLUG_MISMATCH` rule has to be enforced here to stop a second -/// context from landing in a namespace. +/// The other strategic rules only advise at write time (see `put_entity`), +/// so `CONTEXT_SLUG_MISMATCH` is the one that has to be enforced here to stop +/// a second context from landing in a namespace. #[allow(clippy::result_large_err)] fn check_context_slug(entity: &pb::Entity) -> Result<(), Status> { let Some(pb::entity::Kind::BoundedContext(bc)) = entity.kind.as_ref() else { diff --git a/rsworkspace/crates/trogon-atlas-server/src/validation.rs b/rsworkspace/crates/trogon-atlas-server/src/validation.rs index 18fbba9b..c4597f13 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/validation.rs @@ -419,14 +419,20 @@ pub fn rule_catalog() -> Vec { r("DOMAIN_HAS_NO_CORE", Info, "Domain has no CORE subdomain", "A domain without any CORE subdomain is unusual; CORE is what differentiates the system. Audit whether something here should be classified CORE.", Domain, "", "strategic"), + r("DOMAIN_IN_CONTEXT_NAMESPACE", Warning, "Domain declared in a bounded context namespace", + "The domain is the business, not one context's property. Give it its own problem-space namespace and let contexts realize its subdomains across namespaces.", + Domain, "id.namespace", "strategic"), r("REALIZES_MISSING_REF", Error, "Bounded context `realizes` entry missing id", "Every BoundedContext.realizes entry must point at a Subdomain.", BoundedContext, "realizes", "strategic"), r("REALIZES_DANGLING", Error, "Bounded context realizes a Subdomain that does not exist", "The subdomain referenced by BoundedContext.realizes is not in the store.", BoundedContext, "realizes", "strategic"), + r("REALIZES_MIXED_CLASSIFICATIONS", Warning, "Bounded context realizes subdomains of different classifications", + "One boundary carries two investment levels, so one of them is mis-budgeted. Split the context along the classification line.", + BoundedContext, "realizes", "strategic"), r("REALIZES_MULTIPLE", Info, "Bounded context realizes multiple subdomains", - "A BoundedContext realizing more than one subdomain is allowed but unusual; surfaced for awareness.", + "A BoundedContext realizing several subdomains of one classification is allowed but unusual; surfaced for awareness.", BoundedContext, "realizes", "strategic"), r("SUBDOMAIN_MISSING_DOMAIN", Warning, "Subdomain has no parent domain", "Every Subdomain should declare which Domain it belongs to.", @@ -637,6 +643,7 @@ mod catalog_drift_tests { "CROSS_MODEL_REF", "DANGLING_REF", "DOMAIN_HAS_NO_CORE", + "DOMAIN_IN_CONTEXT_NAMESPACE", "DOMAIN_MISSING_PROJECT", "DOMAIN_SLUG_MISMATCH", "EVENT_FIELD_UNSOURCED", @@ -667,6 +674,7 @@ mod catalog_drift_tests { "READ_MODEL_DUPLICATE_SHAPE", "REALIZES_DANGLING", "REALIZES_MISSING_REF", + "REALIZES_MIXED_CLASSIFICATIONS", "REALIZES_MULTIPLE", "RELATIONSHIP_CONTRADICTS_SEAM", "RELATIONSHIP_DANGLING", @@ -2151,7 +2159,29 @@ pub fn validate_scenarios(entity: &pb::Entity, all: &[StoredEntity]) -> Vec 1 { + let classifications = realized_classifications(bc, all); + if classifications.len() > 1 { + let names: Vec = classifications + .iter() + .map(|c| { + c.as_str_name() + .trim_start_matches("CLASSIFICATION_") + .to_lowercase() + }) + .collect(); + issues.push(pb::ValidationIssue { + severity: pb::validation_issue::Severity::Warning as i32, + code: "REALIZES_MIXED_CLASSIFICATIONS".into(), + message: format!( + "context realizes subdomains classified {}; one boundary carries {} investment levels, so one of them is mis-budgeted: split the context along the classification line", + names.join(" and "), + names.len(), + ), + subject: subject.clone(), + subject_field: "realizes".into(), + ..Default::default() + }); + } else if bc.realizes.len() > 1 { issues.push(pb::ValidationIssue { severity: pb::validation_issue::Severity::Info as i32, code: "REALIZES_MULTIPLE".into(), @@ -2185,6 +2215,26 @@ pub fn validate_scenarios(entity: &pb::Entity, all: &[StoredEntity]) -> Vec Vec { out } +fn find_stored<'a>( + all: &'a [StoredEntity], + kind: pb::EntityKind, + id: &pb::Id, +) -> Option<&'a StoredEntity> { + all.iter().find(|se| { + entity_kind(&se.entity) == Some(kind) + && entity_id(&se.entity).is_some_and(|sid| { + sid.namespace == id.namespace && sid.slug == id.slug && sid.version == id.version + }) + }) +} + +/// Classifications of the subdomains a context realizes that resolve in the +/// store. Unclassified subdomains are left out: `SUBDOMAIN_UNCLASSIFIED` +/// already names them, and an absent classification is no investment level. +fn realized_classifications( + bc: &pb::BoundedContext, + all: &[StoredEntity], +) -> BTreeSet { + bc.realizes + .iter() + .filter_map(|r| r.id.as_ref()) + .filter_map(|sid| find_stored(all, pb::EntityKind::Subdomain, sid)) + .filter_map(|se| match se.entity.kind.as_ref() { + Some(pb::entity::Kind::Subdomain(sd)) => { + pb::subdomain::Classification::try_from(sd.classification).ok() + } + _ => None, + }) + .filter(|c| *c != pb::subdomain::Classification::Unspecified) + .collect() +} + +/// Latest stored version per (kind, namespace, slug) for the strategic kinds, +/// so declared-in-namespace collection validates a lineage once, at its head, +/// instead of reporting stale findings from superseded versions. +fn latest_strategic_versions(all: &[StoredEntity]) -> HashMap<(pb::EntityKind, &str, &str), u64> { + let mut latest: HashMap<(pb::EntityKind, &str, &str), u64> = HashMap::new(); + for se in all { + let (kind, id) = match se.entity.kind.as_ref() { + Some(pb::entity::Kind::BoundedContext(x)) => { + (pb::EntityKind::BoundedContext, x.id.as_ref()) + } + Some(pb::entity::Kind::Domain(x)) => (pb::EntityKind::Domain, x.id.as_ref()), + Some(pb::entity::Kind::Subdomain(x)) => (pb::EntityKind::Subdomain, x.id.as_ref()), + _ => continue, + }; + let Some(id) = id else { continue }; + let slot = latest + .entry((kind, id.namespace.as_str(), id.slug.as_str())) + .or_insert(id.version); + *slot = (*slot).max(id.version); + } + latest +} + +fn strategic_subdomain_domain<'a>( + sd: &pb::Subdomain, + all: &'a [StoredEntity], +) -> Option<&'a StoredEntity> { + sd.domain + .as_ref() + .and_then(|r| r.id.as_ref()) + .and_then(|did| find_stored(all, pb::EntityKind::Domain, did)) +} + +/// Strategic findings for the chain behind the given namespaces: the bounded +/// contexts, domains and subdomains declared in them (latest version of +/// each), the subdomains those contexts realize, and the domains of all of +/// those. Every subdomain of a collected domain is included too, so an +/// unrealized subdomain surfaces. Each entity is validated once however many +/// namespaces reach it. +/// +/// Refs resolve by exact id, the same convention `REALIZES_DANGLING` and +/// `SUBDOMAIN_DANGLING_DOMAIN` use: a ref that names no stored version is +/// reported as dangling, not silently resolved to the latest. +#[must_use] +pub fn strategic_chain_issues<'a>( + namespaces: impl IntoIterator, + all: &[StoredEntity], +) -> Vec { + let namespaces: HashSet<&str> = namespaces.into_iter().collect(); + let latest = latest_strategic_versions(all); + let declared_here = |kind: pb::EntityKind, id: Option<&pb::Id>| { + id.is_some_and(|id| { + namespaces.contains(id.namespace.as_str()) + && latest + .get(&(kind, id.namespace.as_str(), id.slug.as_str())) + .is_some_and(|v| *v == id.version) + }) + }; + let mut chain: Vec<(pb::EntityKind, &StoredEntity)> = Vec::new(); + for se in all { + match se.entity.kind.as_ref() { + Some(pb::entity::Kind::BoundedContext(bc)) + if declared_here(pb::EntityKind::BoundedContext, bc.id.as_ref()) => + { + chain.push((pb::EntityKind::BoundedContext, se)); + for sid in bc.realizes.iter().filter_map(|r| r.id.as_ref()) { + let Some(sub) = find_stored(all, pb::EntityKind::Subdomain, sid) else { + continue; + }; + chain.push((pb::EntityKind::Subdomain, sub)); + let Some(pb::entity::Kind::Subdomain(sd)) = sub.entity.kind.as_ref() else { + continue; + }; + if let Some(domain) = strategic_subdomain_domain(sd, all) { + chain.push((pb::EntityKind::Domain, domain)); + } + } + } + Some(pb::entity::Kind::Domain(d)) + if declared_here(pb::EntityKind::Domain, d.id.as_ref()) => + { + chain.push((pb::EntityKind::Domain, se)); + } + Some(pb::entity::Kind::Subdomain(sd)) + if declared_here(pb::EntityKind::Subdomain, sd.id.as_ref()) => + { + chain.push((pb::EntityKind::Subdomain, se)); + if let Some(domain) = strategic_subdomain_domain(sd, all) { + chain.push((pb::EntityKind::Domain, domain)); + } + } + _ => {} + } + } + let collected_domains: HashSet<(&str, &str)> = chain + .iter() + .filter_map(|(kind, se)| match (kind, se.entity.kind.as_ref()) { + (pb::EntityKind::Domain, Some(pb::entity::Kind::Domain(d))) => { + d.id.as_ref() + .map(|id| (id.namespace.as_str(), id.slug.as_str())) + } + _ => None, + }) + .collect(); + for se in all { + let Some(pb::entity::Kind::Subdomain(sd)) = se.entity.kind.as_ref() else { + continue; + }; + let belongs = sd + .domain + .as_ref() + .and_then(|r| r.id.as_ref()) + .is_some_and(|did| { + collected_domains.contains(&(did.namespace.as_str(), did.slug.as_str())) + }); + if belongs && declared_latest(&latest, pb::EntityKind::Subdomain, sd.id.as_ref()) { + chain.push((pb::EntityKind::Subdomain, se)); + } + } + let mut seen: HashSet = HashSet::new(); + chain + .into_iter() + .filter(|(kind, se)| { + entity_id(&se.entity).is_some_and(|id| seen.insert(pb::EntityKey::new(*kind, id))) + }) + .flat_map(|(_, se)| validate_scenarios(&se.entity, all)) + .collect() +} + +fn declared_latest( + latest: &HashMap<(pb::EntityKind, &str, &str), u64>, + kind: pb::EntityKind, + id: Option<&pb::Id>, +) -> bool { + id.is_some_and(|id| { + latest + .get(&(kind, id.namespace.as_str(), id.slug.as_str())) + .is_some_and(|v| *v == id.version) + }) +} + +/// A change to one strategic entity: the key of the entity a write put or +/// merged. +pub type StrategicChange = (pb::EntityKind, pb::Id); + +type IssueKey = (String, i32, String, String, u64, String); + +fn issue_key(issue: &pb::ValidationIssue) -> IssueKey { + let (kind, ns, slug, version) = issue + .subject + .as_ref() + .map(|s| { + let id = s.id.clone().unwrap_or_default(); + (s.kind, id.namespace, id.slug, id.version) + }) + .unwrap_or_default(); + ( + issue.code.clone(), + kind, + ns, + slug, + version, + issue.subject_field.clone(), + ) +} + +/// The strategic findings each change causes, judged against `world` (the +/// store as it will be once every change landed, so the order of changes +/// does not matter). A change covers its own entity and the entities whose +/// verdict it can move: +/// +/// - a domain: itself; +/// - a subdomain: itself, every context realizing it, and its domain; +/// - a bounded context: itself, the subdomains it realizes, and the domains +/// declared in its namespace. +/// +/// A finding is reported once, under the change whose entity it is about +/// (the subject), or else under the first change that reaches it. Findings +/// are advisory; the caller decides what, if anything, they block. +#[must_use] +pub fn strategic_issues_by_change( + changes: &[StrategicChange], + world: &[StoredEntity], +) -> Vec> { + let latest = latest_strategic_versions(world); + let mut reached: Vec<(usize, pb::ValidationIssue)> = Vec::new(); + for (index, (kind, id)) in changes.iter().enumerate() { + let Some(own) = find_stored(world, *kind, id) else { + continue; + }; + let mut targets: Vec<&StoredEntity> = vec![own]; + match own.entity.kind.as_ref() { + Some(pb::entity::Kind::Subdomain(sd)) => { + for se in world { + let Some(pb::entity::Kind::BoundedContext(bc)) = se.entity.kind.as_ref() else { + continue; + }; + if bc + .realizes + .iter() + .filter_map(|r| r.id.as_ref()) + .any(|rid| rid.namespace == id.namespace && rid.slug == id.slug) + { + targets.push(se); + } + } + if let Some(domain) = strategic_subdomain_domain(sd, world) { + targets.push(domain); + } + } + Some(pb::entity::Kind::BoundedContext(bc)) => { + for sid in bc.realizes.iter().filter_map(|r| r.id.as_ref()) { + if let Some(sub) = find_stored(world, pb::EntityKind::Subdomain, sid) { + targets.push(sub); + } + } + for se in world { + if let Some(pb::entity::Kind::Domain(d)) = se.entity.kind.as_ref() { + if d.id + .as_ref() + .is_some_and(|did| did.namespace == id.namespace) + && declared_latest(&latest, pb::EntityKind::Domain, d.id.as_ref()) + { + targets.push(se); + } + } + } + } + _ => {} + } + for target in targets { + reached.extend( + validate_scenarios(&target.entity, world) + .into_iter() + .map(|issue| (index, issue)), + ); + } + } + + let own_change: HashMap<(i32, &str, &str, u64), usize> = changes + .iter() + .enumerate() + .map(|(i, (kind, id))| { + ( + ( + *kind as i32, + id.namespace.as_str(), + id.slug.as_str(), + id.version, + ), + i, + ) + }) + .collect(); + let mut seen: HashSet = HashSet::new(); + let mut by_change: Vec> = vec![Vec::new(); changes.len()]; + for (index, issue) in reached { + if !seen.insert(issue_key(&issue)) { + continue; + } + let owner = issue + .subject + .as_ref() + .and_then(|s| { + let id = s.id.as_ref()?; + own_change + .get(&(s.kind, id.namespace.as_str(), id.slug.as_str(), id.version)) + .copied() + }) + .unwrap_or(index); + by_change[owner].push(issue); + } + by_change +} + +/// The strategic findings each deletion leaves behind. `before` is the world +/// with the deletions not yet applied and `after` the world without them; a +/// finding is reported only when it appears in `after` and not in `before`, +/// so what a delete reports is what it caused. A deletion covers the +/// surviving entities whose verdict it can move: +/// +/// - a domain: the subdomains that named it; +/// - a subdomain: the contexts realizing it and its domain; +/// - a bounded context: the subdomains it realized and the domains declared +/// in its namespace. +/// +/// A finding is attached to the first deletion that reaches it. Findings +/// already in `reported` (for instance on a put of the same batch) are not +/// repeated. Findings are advisory; deletes are never blocked by them. +#[must_use] +pub fn strategic_issues_for_deletes( + deleted: &[StrategicChange], + before: &[StoredEntity], + after: &[StoredEntity], + reported: &[pb::ValidationIssue], +) -> Vec> { + let latest_before = latest_strategic_versions(before); + let mut seen: HashSet = reported.iter().map(issue_key).collect(); + let mut by_delete: Vec> = vec![Vec::new(); deleted.len()]; + for (index, (kind, id)) in deleted.iter().enumerate() { + let Some(gone) = find_stored(before, *kind, id) else { + continue; + }; + let mut targets: Vec<&StoredEntity> = Vec::new(); + match gone.entity.kind.as_ref() { + Some(pb::entity::Kind::Domain(_)) => { + for se in before { + let Some(pb::entity::Kind::Subdomain(sd)) = se.entity.kind.as_ref() else { + continue; + }; + if sd + .domain + .as_ref() + .and_then(|r| r.id.as_ref()) + .is_some_and(|did| did.namespace == id.namespace && did.slug == id.slug) + { + targets.push(se); + } + } + } + Some(pb::entity::Kind::Subdomain(sd)) => { + for se in before { + let Some(pb::entity::Kind::BoundedContext(bc)) = se.entity.kind.as_ref() else { + continue; + }; + if bc + .realizes + .iter() + .filter_map(|r| r.id.as_ref()) + .any(|rid| rid.namespace == id.namespace && rid.slug == id.slug) + { + targets.push(se); + } + } + if let Some(domain) = strategic_subdomain_domain(sd, before) { + targets.push(domain); + } + } + Some(pb::entity::Kind::BoundedContext(bc)) => { + for sid in bc.realizes.iter().filter_map(|r| r.id.as_ref()) { + if let Some(sub) = find_stored(before, pb::EntityKind::Subdomain, sid) { + targets.push(sub); + } + } + for se in before { + if let Some(pb::entity::Kind::Domain(d)) = se.entity.kind.as_ref() { + if d.id + .as_ref() + .is_some_and(|did| did.namespace == id.namespace) + && declared_latest( + &latest_before, + pb::EntityKind::Domain, + d.id.as_ref(), + ) + { + targets.push(se); + } + } + } + } + _ => {} + } + for target in targets { + let (Some(target_kind), Some(target_id)) = + (entity_kind(&target.entity), entity_id(&target.entity)) + else { + continue; + }; + let Some(survivor) = find_stored(after, target_kind, target_id) else { + continue; + }; + let existing: HashSet = validate_scenarios(&target.entity, before) + .iter() + .map(issue_key) + .collect(); + for issue in validate_scenarios(&survivor.entity, after) { + let key = issue_key(&issue); + if !existing.contains(&key) && seen.insert(key) { + by_delete[index].push(issue); + } + } + } + } + by_delete +} + +/// Everything wrong with one model: its own content and the strategic chain +/// of its namespace, so the findings a model's context inherits from the +/// problem space are not hidden until someone validates those entities. +#[must_use] pub fn validate_model(model: &pb::EventModel, all: &[StoredEntity]) -> Vec { + let mut issues = validate_model_content(model, all); + if let Some(id) = model.id.as_ref() { + issues.extend(strategic_chain_issues([id.namespace.as_str()], all)); + } + issues +} + +#[must_use] +pub fn validate_model_content( + model: &pb::EventModel, + all: &[StoredEntity], +) -> Vec { let mut issues: Vec = Vec::new(); let known: HashSet = all @@ -10684,6 +11170,269 @@ mod knowledge_graph_tests { "{issues:?}" ); } + + fn count_code(issues: &[pb::ValidationIssue], code: &str) -> usize { + issues.iter().filter(|i| i.code == code).count() + } + + fn event_model(ns: &str, slug: &str) -> pb::EventModel { + pb::EventModel { + id: Some(id(ns, slug)), + title: slug.into(), + ..Default::default() + } + } + + #[test] + fn domain_in_context_namespace_warns() { + let dom = domain("accounts", "accounts"); + let all = vec![stored(dom.clone()), stored(context("accounts", &[]))]; + let issues = validate_scenarios(&dom, &all); + assert_eq!( + count_code(&issues, "DOMAIN_IN_CONTEXT_NAMESPACE"), + 1, + "{issues:?}" + ); + assert!( + issues + .iter() + .filter(|i| i.code == "DOMAIN_IN_CONTEXT_NAMESPACE") + .all(|i| i.severity == pb::validation_issue::Severity::Warning as i32), + "{issues:?}" + ); + } + + #[test] + fn domain_in_its_own_namespace_is_clean() { + let dom = domain("marketplace", "marketplace"); + let all = vec![stored(dom.clone()), stored(context("accounts", &[]))]; + let issues = validate_scenarios(&dom, &all); + assert_eq!( + count_code(&issues, "DOMAIN_IN_CONTEXT_NAMESPACE"), + 0, + "{issues:?}" + ); + } + + #[test] + fn mixed_classifications_warn_instead_of_multiple() { + let all = vec![ + stored(domain("marketplace", "marketplace")), + stored(subdomain( + "marketplace", + "ordering", + Some("marketplace"), + true, + )), + stored(subdomain_supporting( + "marketplace", + "fulfillment", + Some("marketplace"), + )), + ]; + let mixed = context( + "accounts", + &[("marketplace", "ordering"), ("marketplace", "fulfillment")], + ); + let issues = validate_scenarios(&mixed, &all); + assert_eq!( + count_code(&issues, "REALIZES_MIXED_CLASSIFICATIONS"), + 1, + "{issues:?}" + ); + assert_eq!(count_code(&issues, "REALIZES_MULTIPLE"), 0, "{issues:?}"); + assert!( + issues + .iter() + .filter(|i| i.code == "REALIZES_MIXED_CLASSIFICATIONS") + .all(|i| i.severity == pb::validation_issue::Severity::Warning as i32), + "{issues:?}" + ); + assert!( + issues + .iter() + .filter(|i| i.code == "REALIZES_MIXED_CLASSIFICATIONS") + .all(|i| i.message.contains("core and supporting")), + "{issues:?}" + ); + } + + #[test] + fn same_classification_stays_info_multiple() { + let all = vec![ + stored(domain("marketplace", "marketplace")), + stored(subdomain( + "marketplace", + "ordering", + Some("marketplace"), + true, + )), + stored(subdomain( + "marketplace", + "pricing", + Some("marketplace"), + true, + )), + ]; + let issues = validate_scenarios( + &context( + "accounts", + &[("marketplace", "ordering"), ("marketplace", "pricing")], + ), + &all, + ); + assert_eq!(count_code(&issues, "REALIZES_MULTIPLE"), 1, "{issues:?}"); + assert_eq!( + count_code(&issues, "REALIZES_MIXED_CLASSIFICATIONS"), + 0, + "{issues:?}" + ); + } + + #[test] + fn unclassified_and_unresolved_subdomains_do_not_mix() { + let all = vec![ + stored(domain("marketplace", "marketplace")), + stored(subdomain( + "marketplace", + "ordering", + Some("marketplace"), + true, + )), + stored(subdomain( + "marketplace", + "unsorted", + Some("marketplace"), + false, + )), + ]; + let issues = validate_scenarios( + &context( + "accounts", + &[ + ("marketplace", "ordering"), + ("marketplace", "unsorted"), + ("marketplace", "ghost"), + ], + ), + &all, + ); + assert_eq!( + count_code(&issues, "REALIZES_MIXED_CLASSIFICATIONS"), + 0, + "{issues:?}" + ); + assert_eq!(count_code(&issues, "REALIZES_MULTIPLE"), 1, "{issues:?}"); + } + + fn mixed_store() -> Vec { + vec![ + stored(domain("shop", "retail")), + stored(subdomain("shop", "ordering", Some("shop"), true)), + stored(subdomain_supporting("shop", "fulfillment", Some("shop"))), + stored(context( + "shop", + &[("shop", "ordering"), ("shop", "fulfillment")], + )), + ] + } + + #[test] + fn model_validation_includes_the_strategic_chain() { + let all = mixed_store(); + let issues = validate_model(&event_model("shop", "checkout"), &all); + for code in [ + "DOMAIN_SLUG_MISMATCH", + "DOMAIN_IN_CONTEXT_NAMESPACE", + "REALIZES_MIXED_CLASSIFICATIONS", + ] { + assert_eq!(count_code(&issues, code), 1, "{code}: {issues:?}"); + } + assert!( + validate_model_content(&event_model("shop", "checkout"), &all) + .iter() + .all(|i| i.rule_category != "strategic"), + ); + } + + #[test] + fn model_validation_ignores_unrelated_strategic_entities() { + let issues = validate_model(&event_model("elsewhere", "checkout"), &mixed_store()); + assert_eq!( + issues + .iter() + .filter(|i| i.rule_category == "strategic") + .count(), + 0, + "{issues:?}" + ); + } + + #[test] + fn chain_validates_a_shared_domain_once() { + let mut all = vec![ + stored(domain("business", "business")), + stored(subdomain("business", "ordering", Some("business"), true)), + stored(subdomain("business", "pricing", Some("business"), true)), + ]; + all.push(stored(context("orders", &[("business", "ordering")]))); + all.push(stored(context("prices", &[("business", "pricing")]))); + let issues = strategic_chain_issues(["orders", "prices"], &all); + assert_eq!(count_code(&issues, "DOMAIN_HAS_NO_CORE"), 0, "{issues:?}"); + assert_eq!( + count_code(&issues, "DOMAIN_IN_CONTEXT_NAMESPACE"), + 0, + "{issues:?}" + ); + + let mut shared_no_core = vec![ + stored(domain("business", "business")), + stored(subdomain_supporting( + "business", + "ordering", + Some("business"), + )), + stored(subdomain_supporting( + "business", + "pricing", + Some("business"), + )), + ]; + shared_no_core.push(stored(context("orders", &[("business", "ordering")]))); + shared_no_core.push(stored(context("prices", &[("business", "pricing")]))); + let issues = strategic_chain_issues(["orders", "prices"], &shared_no_core); + assert_eq!(count_code(&issues, "DOMAIN_HAS_NO_CORE"), 1, "{issues:?}"); + } + + #[test] + fn chain_reaches_subdomains_declared_in_a_problem_space_namespace() { + let all = vec![ + stored(domain("business", "business")), + stored(subdomain("business", "ordering", Some("business"), true)), + stored(subdomain("business", "pricing", Some("business"), true)), + stored(context("orders", &[("business", "ordering")])), + ]; + let issues = strategic_chain_issues(["business"], &all); + let unrealized: Vec<_> = issues + .iter() + .filter(|i| i.code == "SUBDOMAIN_UNREALIZED") + .filter_map(|i| i.subject.as_ref()?.id.as_ref()) + .map(|id| id.slug.as_str()) + .collect(); + assert_eq!(unrealized, ["pricing"], "{issues:?}"); + } + + #[test] + fn chain_reaches_subdomains_of_a_collected_domain_from_another_namespace() { + let all = vec![ + stored(domain("business", "business")), + stored(subdomain("business", "ordering", Some("business"), true)), + stored(subdomain("business", "pricing", Some("business"), true)), + stored(context("orders", &[("business", "ordering")])), + ]; + let issues = strategic_chain_issues(["orders"], &all); + assert_eq!(count_code(&issues, "SUBDOMAIN_UNREALIZED"), 1, "{issues:?}"); + } } #[cfg(test)] diff --git a/rsworkspace/crates/trogon-atlas-server/tests/account_deletion_fixture.rs b/rsworkspace/crates/trogon-atlas-server/tests/account_deletion_fixture.rs index 4405e434..4a6cf89a 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/account_deletion_fixture.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/account_deletion_fixture.rs @@ -15,9 +15,10 @@ fn id(ns: &str, slug: &str, version: u64) -> pb::Id { } } -fn fixture_dir() -> PathBuf { +fn manifests_dir(namespace: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../examples/manifests/acme-customer-data-deletion") + .join("../../../examples/manifests") + .join(namespace) } fn put(entity: pb::Entity) -> pb::BatchMutateOp { @@ -34,11 +35,14 @@ fn put(entity: pb::Entity) -> pb::BatchMutateOp { } fn load_manifests() -> Vec { - trogon_atlas_client::manifest::load_paths(&[fixture_dir()]) - .expect("acme-customer-data-deletion manifests should load") - .into_iter() - .map(|m| m.entity) - .collect() + trogon_atlas_client::manifest::load_paths(&[ + manifests_dir("acme"), + manifests_dir("acme-customer-data-deletion"), + ]) + .expect("acme-customer-data-deletion manifests should load") + .into_iter() + .map(|m| m.entity) + .collect() } async fn seed_entities(entities: Vec) -> EventModelServiceImpl { diff --git a/rsworkspace/crates/trogon-atlas-server/tests/branching.rs b/rsworkspace/crates/trogon-atlas-server/tests/branching.rs index c3ef0c4a..20afbd2e 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/branching.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/branching.rs @@ -1785,10 +1785,28 @@ async fn merge_branch_dry_run_splits_counts_by_class_and_reports_findings() { !resp.validation.is_empty(), "a dry run must report the findings the merge leaves behind" ); - assert!(resp + let errors: Vec<&pb::ValidationIssue> = resp .validation .iter() - .all(|i| i.severity != pb::validation_issue::Severity::Error as i32)); + .filter(|i| i.severity == pb::validation_issue::Severity::Error as i32) + .collect(); + assert!( + errors.iter().any(|i| i.code == "DOMAIN_SLUG_MISMATCH"), + "a strategic Error rides a VALIDATED merge: {errors:?}" + ); + assert!( + errors.iter().all(|i| { + i.subject.as_ref().is_some_and(|s| { + [ + pb::EntityKind::Domain, + pb::EntityKind::Subdomain, + pb::EntityKind::BoundedContext, + ] + .contains(&pb::EntityKind::try_from(s.kind).unwrap()) + }) + }), + "only strategic findings may be Errors on a non-INVALID merge: {errors:?}" + ); let applied = c .merge_branch(pb::MergeBranchRequest { diff --git a/rsworkspace/crates/trogon-atlas-server/tests/example_manifests_validate.rs b/rsworkspace/crates/trogon-atlas-server/tests/example_manifests_validate.rs index 8d319490..38c609ba 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/example_manifests_validate.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/example_manifests_validate.rs @@ -7,8 +7,11 @@ use trogon_atlas_proto as pb; use trogon_atlas_proto::event_model_service_server::EventModelService; use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; -const NAMESPACES: [(&str, &str); 4] = [ +const PROBLEM_SPACE: &str = "acme"; + +const NAMESPACES: [(&str, &str); 5] = [ ("acme-storefront", "acme-storefront-model"), + ("acme-fulfillment", "acme-fulfillment-model"), ("acme-reviews", "acme-reviews-model"), ( "acme-customer-data-deletion", @@ -35,9 +38,9 @@ fn put(entity: pb::Entity) -> pb::BatchMutateOp { } async fn seed_all_examples() -> EventModelServiceImpl { - let paths: Vec = NAMESPACES - .iter() - .map(|(ns, _)| manifests_dir().join(ns)) + let paths: Vec = std::iter::once(PROBLEM_SPACE) + .chain(NAMESPACES.iter().map(|(ns, _)| *ns)) + .map(|ns| manifests_dir().join(ns)) .collect(); let ops: Vec = trogon_atlas_client::manifest::load_paths(&paths) .expect("example manifests should load") @@ -104,33 +107,45 @@ async fn example_models_raise_no_errors_or_warnings() { } #[tokio::test(flavor = "multi_thread")] -async fn example_projects_raise_no_errors_or_warnings() { +async fn example_project_raises_no_errors_or_warnings() { let svc = seed_all_examples().await; - let mut findings = Vec::new(); + let resp = svc + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::ProjectId(pb::Id { + namespace: PROBLEM_SPACE.into(), + slug: PROBLEM_SPACE.into(), + version: 1, + })), + })) + .await + .unwrap() + .into_inner(); + let reported: Vec = resp + .reports + .iter() + .filter_map(|r| r.event_model_id.as_ref()) + .map(|id| id.namespace.clone()) + .collect(); for (ns, _) in NAMESPACES { - let resp = svc - .validate_project(Request::new(pb::ValidateProjectRequest { - scope: Some(pb::validate_project_request::Scope::ProjectId(pb::Id { - namespace: ns.into(), - slug: ns.into(), - version: 1, - })), - })) - .await - .unwrap() - .into_inner(); - assert!(!resp.reports.is_empty(), "{ns} project reported no models"); - for report in resp.reports { - for i in report.issues { - if i.severity >= pb::validation_issue::Severity::Warning as i32 { - findings.push(format!("{ns} {} {:?} :: {}", i.code, i.subject, i.message)); - } + assert!( + reported.iter().any(|reported| reported == ns), + "the {PROBLEM_SPACE} project reported no model for {ns}" + ); + } + let mut findings = Vec::new(); + for report in resp.reports { + for i in report.issues { + if i.severity >= pb::validation_issue::Severity::Warning as i32 { + findings.push(format!( + "{PROBLEM_SPACE} {} {:?} :: {}", + i.code, i.subject, i.message + )); } } } assert!( findings.is_empty(), - "example projects must validate cleanly:\n{}", + "the example project must validate cleanly:\n{}", findings.join("\n") ); } diff --git a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs index 6e9583f8..24bc744d 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs @@ -127,6 +127,138 @@ async fn validate_project_namespace_scope_covers_only_that_namespace() { assert_eq!(validated, ["vp-ns-a/checkout", "vp-ns-a/refunds"]); } +async fn put_entity(svc: &EventModelServiceImpl, entity: pb::Entity) { + svc.put_entity(Request::new(pb::PutEntityRequest { + operation_id: String::new(), + entity: Some(entity), + create_only: true, + if_match: String::new(), + validate_only: false, + force: false, + })) + .await + .unwrap(); +} + +async fn seed_confused_context(svc: &EventModelServiceImpl, ns: &str) { + let domain_id = id(ns, "retail", 1); + put_entity( + svc, + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::Domain(pb::Domain { + id: Some(domain_id.clone()), + title: "Retail".into(), + ..Default::default() + })), + }, + ) + .await; + let mut realizes = Vec::new(); + for (slug, classification) in [ + ("ordering", pb::subdomain::Classification::Core), + ("fulfillment", pb::subdomain::Classification::Supporting), + ] { + put_entity( + svc, + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::Subdomain(pb::Subdomain { + id: Some(id(ns, slug, 1)), + title: slug.into(), + domain: Some(pb::DomainRef { + id: Some(domain_id.clone()), + }), + classification: classification as i32, + ..Default::default() + })), + }, + ) + .await; + realizes.push(pb::SubdomainRef { + id: Some(id(ns, slug, 1)), + }); + } + put_entity( + svc, + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::BoundedContext(pb::BoundedContext { + id: Some(id(ns, ns, 1)), + title: ns.into(), + realizes, + ..Default::default() + })), + }, + ) + .await; +} + +fn codes_of(issues: &[pb::ValidationIssue], code: &str) -> usize { + issues.iter().filter(|i| i.code == code).count() +} + +const STRATEGIC_FINDINGS: [&str; 3] = [ + "DOMAIN_SLUG_MISMATCH", + "DOMAIN_IN_CONTEXT_NAMESPACE", + "REALIZES_MIXED_CLASSIFICATIONS", +]; + +#[tokio::test(flavor = "multi_thread")] +async fn validate_event_model_reports_the_strategic_chain() { + let svc = svc().await; + seed_confused_context(&svc, "vem-chain").await; + put_entity(&svc, make_event_model("vem-chain", "checkout")).await; + + let issues = svc + .validate_event_model(Request::new(pb::ValidateEventModelRequest { + event_model: None, + event_model_id: Some(id("vem-chain", "checkout", 1)), + })) + .await + .unwrap() + .into_inner() + .issues; + for code in STRATEGIC_FINDINGS { + assert_eq!(codes_of(&issues, code), 1, "{code}: {issues:?}"); + } + assert_eq!(codes_of(&issues, "REALIZES_MULTIPLE"), 0, "{issues:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn validate_project_reports_the_strategic_chain_once() { + let svc = svc().await; + seed_confused_context(&svc, "vp-chain").await; + put_entity(&svc, make_event_model("vp-chain", "checkout")).await; + put_entity(&svc, make_event_model("vp-chain", "refunds")).await; + + let resp = svc + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "vp-chain".into(), + )), + })) + .await + .unwrap() + .into_inner(); + let all_issues: Vec = resp + .reports + .iter() + .flat_map(|r| r.issues.iter().cloned()) + .collect(); + for code in STRATEGIC_FINDINGS { + assert_eq!(codes_of(&all_issues, code), 1, "{code}: {all_issues:?}"); + } + let store_report = resp + .reports + .iter() + .find(|r| r.event_model_id.is_none()) + .expect("strategic findings ride the store report"); + for code in STRATEGIC_FINDINGS { + assert_eq!(codes_of(&store_report.issues, code), 1, "{code}"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn validate_project_rejects_an_empty_namespace_scope() { let svc = svc().await; diff --git a/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs b/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs new file mode 100644 index 00000000..c6879cea --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs @@ -0,0 +1,834 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::large_futures)] + +use std::{path::PathBuf, sync::Arc}; + +use pb::subdomain::Classification; +use tonic::Request; +use trogon_atlas_proto as pb; +use trogon_atlas_proto::event_model_service_server::EventModelService; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +const BRANCH: &str = "x-trogon-atlas-branch"; + +fn id(ns: &str, slug: &str) -> pb::Id { + pb::Id { + namespace: ns.into(), + slug: slug.into(), + version: 1, + } +} + +fn domain(ns: &str, slug: &str) -> pb::Entity { + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::Domain(pb::Domain { + id: Some(id(ns, slug)), + title: slug.into(), + ..Default::default() + })), + } +} + +fn subdomain(ns: &str, slug: &str, classification: Classification) -> pb::Entity { + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::Subdomain(pb::Subdomain { + id: Some(id(ns, slug)), + title: slug.into(), + domain: Some(pb::DomainRef { + id: Some(id(ns, ns)), + }), + classification: classification as i32, + ..Default::default() + })), + } +} + +fn context(ns: &str, realizes: &[(&str, &str)]) -> pb::Entity { + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::BoundedContext(pb::BoundedContext { + id: Some(id(ns, ns)), + title: ns.into(), + realizes: realizes + .iter() + .map(|(sns, slug)| pb::SubdomainRef { + id: Some(id(sns, slug)), + }) + .collect(), + ..Default::default() + })), + } +} + +fn put_request(entity: pb::Entity, validate_only: bool) -> pb::PutEntityRequest { + pb::PutEntityRequest { + operation_id: String::new(), + entity: Some(entity), + create_only: false, + if_match: String::new(), + validate_only, + force: false, + } +} + +fn batch_put(entity: pb::Entity) -> pb::BatchMutateOp { + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(put_request(entity, false))), + } +} + +async fn svc() -> EventModelServiceImpl { + let nats = trogon_atlas_testsupport::shared().await; + let store = nats.store().await; + EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap() +} + +async fn put(svc: &EventModelServiceImpl, entity: pb::Entity) -> Vec { + svc.put_entity(Request::new(put_request(entity, false))) + .await + .unwrap() + .into_inner() + .validation +} + +fn count(issues: &[pb::ValidationIssue], code: &str) -> usize { + issues.iter().filter(|i| i.code == code).count() +} + +fn about(issue: &pb::ValidationIssue) -> (pb::EntityKind, String, String) { + let subject = issue + .subject + .as_ref() + .expect("strategic findings name a subject"); + let id = subject.id.as_ref().unwrap(); + ( + pb::EntityKind::try_from(subject.kind).unwrap(), + id.namespace.clone(), + id.slug.clone(), + ) +} + +async fn batch( + svc: &EventModelServiceImpl, + entities: Vec, + validate_only: bool, +) -> pb::BatchMutateResponse { + svc.batch_mutate(Request::new(pb::BatchMutateRequest { + operation_id: String::new(), + ops: entities.into_iter().map(batch_put).collect(), + validate_only, + })) + .await + .unwrap() + .into_inner() +} + +fn op_validation(resp: &pb::BatchMutateResponse, index: usize) -> &[pb::ValidationIssue] { + match resp.results[index].result.as_ref() { + Some(pb::batch_mutate_op_result::Result::Put(put)) => &put.validation, + other => panic!("op {index} is not a put result: {other:?}"), + } +} + +async fn exists(svc: &EventModelServiceImpl, kind: pb::EntityKind, id: pb::Id) -> bool { + svc.get_entity(Request::new(pb::GetEntityRequest { + kind: kind as i32, + id: Some(id), + })) + .await + .is_ok() +} + +#[tokio::test(flavor = "multi_thread")] +async fn putting_a_domain_beside_a_context_reports_it_and_still_writes() { + let svc = svc().await; + put(&svc, context("sw-dom", &[])).await; + + let issues = put(&svc, domain("sw-dom", "sw-dom")).await; + + assert_eq!( + count(&issues, "DOMAIN_IN_CONTEXT_NAMESPACE"), + 1, + "{issues:?}" + ); + assert!(exists(&svc, pb::EntityKind::Domain, id("sw-dom", "sw-dom")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_domain_error_finding_does_not_block_the_put() { + let svc = svc().await; + + let issues = put(&svc, domain("sw-slug", "other")).await; + + let finding = issues + .iter() + .find(|i| i.code == "DOMAIN_SLUG_MISMATCH") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + finding.severity, + pb::validation_issue::Severity::Error as i32 + ); + assert!(exists(&svc, pb::EntityKind::Domain, id("sw-slug", "other")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn putting_a_context_into_a_domain_namespace_reports_the_domain() { + let svc = svc().await; + put(&svc, domain("sw-bc", "sw-bc")).await; + + let issues = put(&svc, context("sw-bc", &[])).await; + + let finding = issues + .iter() + .find(|i| i.code == "DOMAIN_IN_CONTEXT_NAMESPACE") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + (pb::EntityKind::Domain, "sw-bc".into(), "sw-bc".into()) + ); + assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-bc", "sw-bc")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_context_put_before_its_subdomain_is_dangling_but_written() { + let svc = svc().await; + + let issues = put(&svc, context("sw-order", &[("sw-order-ps", "ordering")])).await; + + assert_eq!(count(&issues, "REALIZES_DANGLING"), 1, "{issues:?}"); + assert!( + exists( + &svc, + pb::EntityKind::BoundedContext, + id("sw-order", "sw-order") + ) + .await + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reclassifying_a_subdomain_reports_the_context_it_makes_mixed() { + let svc = svc().await; + let ps = "sw-mixed-ps"; + put(&svc, domain(ps, ps)).await; + put(&svc, subdomain(ps, "ordering", Classification::Core)).await; + put(&svc, subdomain(ps, "billing", Classification::Core)).await; + let clean = put( + &svc, + context("sw-mixed", &[(ps, "ordering"), (ps, "billing")]), + ) + .await; + assert_eq!( + count(&clean, "REALIZES_MIXED_CLASSIFICATIONS"), + 0, + "{clean:?}" + ); + + let issues = put(&svc, subdomain(ps, "billing", Classification::Supporting)).await; + + let finding = issues + .iter() + .find(|i| i.code == "REALIZES_MIXED_CLASSIFICATIONS") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + ( + pb::EntityKind::BoundedContext, + "sw-mixed".into(), + "sw-mixed".into() + ) + ); +} + +fn mixed_batch(ps: &str, ctx: &str) -> Vec { + vec![ + context(ctx, &[(ps, "ordering"), (ps, "billing")]), + subdomain(ps, "ordering", Classification::Core), + subdomain(ps, "billing", Classification::Supporting), + domain(ps, ps), + ] +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_dry_run_batch_reports_mixed_classifications_regardless_of_order() { + let svc = svc().await; + + let resp = batch(&svc, mixed_batch("sw-dry-ps", "sw-dry"), true).await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Validated as i32, + "{resp:?}" + ); + let context_issues = op_validation(&resp, 0); + assert_eq!( + count(context_issues, "REALIZES_MIXED_CLASSIFICATIONS"), + 1, + "{context_issues:?}" + ); + let all: Vec<&pb::ValidationIssue> = (0..4).flat_map(|i| op_validation(&resp, i)).collect(); + assert!( + all.iter().all(|i| i.code != "REALIZES_DANGLING"), + "ordering inside the batch must not cause dangling: {all:?}" + ); + assert!(!exists(&svc, pb::EntityKind::BoundedContext, id("sw-dry", "sw-dry")).await); + assert!(!exists(&svc, pb::EntityKind::Domain, id("sw-dry-ps", "sw-dry-ps")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_applied_batch_carries_the_same_findings_and_persists() { + let svc = svc().await; + + let resp = batch(&svc, mixed_batch("sw-app-ps", "sw-app"), false).await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Applied as i32, + "{resp:?}" + ); + assert_eq!( + count(op_validation(&resp, 0), "REALIZES_MIXED_CLASSIFICATIONS"), + 1 + ); + assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-app", "sw-app")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_batch_finding_about_a_stored_entity_rides_the_op_that_caused_it() { + let svc = svc().await; + put(&svc, domain("sw-cause", "sw-cause")).await; + + let resp = batch(&svc, vec![context("sw-cause", &[])], true).await; + + let issues = op_validation(&resp, 0); + let finding = issues + .iter() + .find(|i| i.code == "DOMAIN_IN_CONTEXT_NAMESPACE") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + (pb::EntityKind::Domain, "sw-cause".into(), "sw-cause".into()) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_finding_is_reported_once_on_the_op_of_its_own_entity() { + let svc = svc().await; + + let resp = batch( + &svc, + vec![context("sw-once", &[]), domain("sw-once", "sw-once")], + true, + ) + .await; + + assert_eq!( + count(op_validation(&resp, 0), "DOMAIN_IN_CONTEXT_NAMESPACE"), + 0 + ); + assert_eq!( + count(op_validation(&resp, 1), "DOMAIN_IN_CONTEXT_NAMESPACE"), + 1 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_clean_problem_space_batch_has_no_strategic_warnings_or_errors() { + let svc = svc().await; + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../examples/manifests/acme"); + let mut entities: Vec = trogon_atlas_client::manifest::load_paths(&[dir]) + .expect("acme manifests should load") + .into_iter() + .map(|m| m.entity) + .collect(); + entities.insert(0, context("acme-ordering", &[("acme", "ordering")])); + + let resp = batch(&svc, entities, true).await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Validated as i32, + "{resp:?}" + ); + let loud: Vec<&pb::ValidationIssue> = (0..resp.results.len()) + .flat_map(|i| op_validation(&resp, i)) + .filter(|i| i.severity >= pb::validation_issue::Severity::Warning as i32) + .collect(); + assert!(loud.is_empty(), "{loud:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn validate_project_reports_an_unrealized_subdomain_in_a_problem_space_namespace() { + let svc = svc().await; + let ps = "sw-vp-ps"; + put(&svc, domain(ps, ps)).await; + put(&svc, subdomain(ps, "ordering", Classification::Core)).await; + + for scope in [ + pb::validate_project_request::Scope::Namespace(ps.into()), + pb::validate_project_request::Scope::DomainId(id(ps, ps)), + ] { + let resp = svc + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(scope.clone()), + })) + .await + .unwrap() + .into_inner(); + let store_report = resp + .reports + .iter() + .find(|r| r.event_model_id.is_none()) + .unwrap_or_else(|| panic!("{scope:?}: {resp:?}")); + let finding = store_report + .issues + .iter() + .find(|i| i.code == "SUBDOMAIN_UNREALIZED") + .unwrap_or_else(|| panic!("{scope:?}: {:?}", store_report.issues)); + assert_eq!( + about(finding), + (pb::EntityKind::Subdomain, ps.into(), "ordering".into()) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn validate_project_by_project_reaches_the_realizing_context() { + let svc = svc().await; + let ps = "sw-pj-ps"; + let mut project_domain = domain(ps, ps); + if let Some(pb::entity::Kind::Domain(d)) = project_domain.kind.as_mut() { + d.project = Some(pb::ProjectRef { + id: Some(id(ps, ps)), + }); + } + put(&svc, project_domain).await; + put(&svc, subdomain(ps, "ordering", Classification::Core)).await; + put(&svc, subdomain(ps, "billing", Classification::Supporting)).await; + put(&svc, context("sw-pj", &[(ps, "ordering"), (ps, "billing")])).await; + + let resp = svc + .validate_project(Request::new(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::ProjectId(id(ps, ps))), + })) + .await + .unwrap() + .into_inner(); + + let store_report = resp + .reports + .iter() + .find(|r| r.event_model_id.is_none()) + .unwrap_or_else(|| panic!("{resp:?}")); + assert_eq!( + count(&store_report.issues, "REALIZES_MIXED_CLASSIFICATIONS"), + 1, + "{:?}", + store_report.issues + ); +} + +async fn merge_world(branch: &str) -> EventModelServiceImpl { + let svc = svc().await; + svc.create_branch(Request::new(pb::CreateBranchRequest { + name: branch.into(), + ..Default::default() + })) + .await + .unwrap(); + svc +} + +fn on_branch(branch: &str, value: T) -> Request { + let mut request = Request::new(value); + request + .metadata_mut() + .insert(BRANCH, branch.parse().unwrap()); + request +} + +#[tokio::test(flavor = "multi_thread")] +async fn merging_a_branch_reports_the_strategic_finding_it_introduces() { + let svc = merge_world("strategic").await; + let ps = "sw-mg-ps"; + put(&svc, domain(ps, ps)).await; + put(&svc, subdomain(ps, "ordering", Classification::Core)).await; + put(&svc, subdomain(ps, "billing", Classification::Supporting)).await; + svc.put_entity(on_branch( + "strategic", + put_request( + context("sw-mg", &[(ps, "ordering"), (ps, "billing")]), + false, + ), + )) + .await + .unwrap(); + + for dry_run in [true, false] { + let merged = svc + .merge_branch(Request::new(pb::MergeBranchRequest { + name: "strategic".into(), + dry_run, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + let expected = if dry_run { + pb::merge_branch_response::Status::Validated + } else { + pb::merge_branch_response::Status::Applied + }; + assert_eq!(merged.status, expected as i32, "{merged:?}"); + assert_eq!( + count(&merged.validation, "REALIZES_MIXED_CLASSIFICATIONS"), + 1, + "dry_run={dry_run}: {:?}", + merged.validation + ); + } + assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-mg", "sw-mg")).await); +} + +fn delete_request( + kind: pb::EntityKind, + id: pb::Id, + mode: pb::delete_entity_request::Mode, +) -> pb::DeleteEntityRequest { + pb::DeleteEntityRequest { + kind: kind as i32, + id: Some(id), + mode: mode as i32, + ..Default::default() + } +} + +async fn delete( + svc: &EventModelServiceImpl, + kind: pb::EntityKind, + id: pb::Id, + mode: pb::delete_entity_request::Mode, +) -> Vec { + svc.delete_entity(Request::new(delete_request(kind, id, mode))) + .await + .unwrap() + .into_inner() + .validation +} + +async fn realized_chain(svc: &EventModelServiceImpl, ps: &str, ctx: &str) { + put(svc, domain(ps, ps)).await; + put(svc, subdomain(ps, "ordering", Classification::Core)).await; + put(svc, context(ctx, &[(ps, "ordering")])).await; +} + +fn batch_delete( + kind: pb::EntityKind, + id: pb::Id, + mode: pb::delete_entity_request::Mode, +) -> pb::BatchMutateOp { + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(delete_request( + kind, id, mode, + ))), + } +} + +async fn batch_ops( + svc: &EventModelServiceImpl, + ops: Vec, + validate_only: bool, +) -> pb::BatchMutateResponse { + svc.batch_mutate(Request::new(pb::BatchMutateRequest { + operation_id: String::new(), + ops, + validate_only, + })) + .await + .unwrap() + .into_inner() +} + +fn delete_validation(resp: &pb::BatchMutateResponse, index: usize) -> &[pb::ValidationIssue] { + match resp.results[index].result.as_ref() { + Some(pb::batch_mutate_op_result::Result::Delete(deleted)) => &deleted.validation, + other => panic!("op {index} is not a delete result: {other:?}"), + } +} + +use pb::delete_entity_request::Mode; + +#[tokio::test(flavor = "multi_thread")] +async fn force_deleting_a_realized_subdomain_reports_the_dangling_context() { + let svc = svc().await; + realized_chain(&svc, "sw-df-ps", "sw-df").await; + + let issues = delete( + &svc, + pb::EntityKind::Subdomain, + id("sw-df-ps", "ordering"), + Mode::Force, + ) + .await; + + let finding = issues + .iter() + .find(|i| i.code == "REALIZES_DANGLING") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + ( + pb::EntityKind::BoundedContext, + "sw-df".into(), + "sw-df".into() + ) + ); + assert!(!exists(&svc, pb::EntityKind::Subdomain, id("sw-df-ps", "ordering")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deleting_the_only_realizing_context_reports_the_unrealized_subdomain() { + let svc = svc().await; + realized_chain(&svc, "sw-dc-ps", "sw-dc").await; + + let issues = delete( + &svc, + pb::EntityKind::BoundedContext, + id("sw-dc", "sw-dc"), + Mode::FailIfReferenced, + ) + .await; + + let finding = issues + .iter() + .find(|i| i.code == "SUBDOMAIN_UNREALIZED") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + ( + pb::EntityKind::Subdomain, + "sw-dc-ps".into(), + "ordering".into() + ) + ); + assert!(!exists(&svc, pb::EntityKind::BoundedContext, id("sw-dc", "sw-dc")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn force_deleting_a_domain_reports_its_subdomains_dangling() { + let svc = svc().await; + realized_chain(&svc, "sw-dd-ps", "sw-dd").await; + + let issues = delete( + &svc, + pb::EntityKind::Domain, + id("sw-dd-ps", "sw-dd-ps"), + Mode::Force, + ) + .await; + + let finding = issues + .iter() + .find(|i| i.code == "SUBDOMAIN_DANGLING_DOMAIN") + .unwrap_or_else(|| panic!("{issues:?}")); + assert_eq!( + about(finding), + ( + pb::EntityKind::Subdomain, + "sw-dd-ps".into(), + "ordering".into() + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_dry_run_delete_previews_the_findings_and_deletes_nothing() { + let svc = svc().await; + realized_chain(&svc, "sw-dr-ps", "sw-dr").await; + + let issues = delete( + &svc, + pb::EntityKind::BoundedContext, + id("sw-dr", "sw-dr"), + Mode::DryRun, + ) + .await; + + assert_eq!(count(&issues, "SUBDOMAIN_UNREALIZED"), 1, "{issues:?}"); + assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-dr", "sw-dr")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_delete_that_leaves_nothing_behind_reports_nothing() { + let svc = svc().await; + put(&svc, domain("sw-dn", "sw-dn")).await; + + let issues = delete( + &svc, + pb::EntityKind::Domain, + id("sw-dn", "sw-dn"), + Mode::FailIfReferenced, + ) + .await; + + assert!(issues.is_empty(), "{issues:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_dry_run_batch_reports_delete_findings_on_the_delete_op() { + let svc = svc().await; + realized_chain(&svc, "sw-bd-ps", "sw-bd").await; + + let resp = batch_ops( + &svc, + vec![batch_delete( + pb::EntityKind::BoundedContext, + id("sw-bd", "sw-bd"), + Mode::FailIfReferenced, + )], + true, + ) + .await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Validated as i32, + "{resp:?}" + ); + let issues = delete_validation(&resp, 0); + assert_eq!(count(issues, "SUBDOMAIN_UNREALIZED"), 1, "{issues:?}"); + assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-bd", "sw-bd")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_applied_batch_reports_delete_findings_and_persists() { + let svc = svc().await; + realized_chain(&svc, "sw-ba-ps", "sw-ba").await; + + let resp = batch_ops( + &svc, + vec![batch_delete( + pb::EntityKind::Subdomain, + id("sw-ba-ps", "ordering"), + Mode::Force, + )], + false, + ) + .await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Applied as i32, + "{resp:?}" + ); + let issues = delete_validation(&resp, 0); + assert_eq!(count(issues, "REALIZES_DANGLING"), 1, "{issues:?}"); + assert!(!exists(&svc, pb::EntityKind::Subdomain, id("sw-ba-ps", "ordering")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_batch_reports_a_finding_once_when_a_put_and_a_delete_both_reach_it() { + let svc = svc().await; + realized_chain(&svc, "sw-bo-ps", "sw-bo").await; + + let resp = batch_ops( + &svc, + vec![ + batch_delete( + pb::EntityKind::Subdomain, + id("sw-bo-ps", "ordering"), + Mode::Force, + ), + batch_put(context("sw-bo", &[("sw-bo-ps", "ordering")])), + ], + true, + ) + .await; + + let on_delete = count(delete_validation(&resp, 0), "REALIZES_DANGLING"); + let on_put = count(op_validation(&resp, 1), "REALIZES_DANGLING"); + assert_eq!((on_delete, on_put), (0, 1), "{resp:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn merging_a_strategic_error_is_reported_without_blocking() { + let branch = "strategic-error"; + let svc = merge_world(branch).await; + svc.put_entity(on_branch( + branch, + put_request(domain("sw-me", "other"), false), + )) + .await + .unwrap(); + + for dry_run in [true, false] { + let merged = svc + .merge_branch(Request::new(pb::MergeBranchRequest { + name: branch.into(), + dry_run, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + let expected = if dry_run { + pb::merge_branch_response::Status::Validated + } else { + pb::merge_branch_response::Status::Applied + }; + assert_eq!(merged.status, expected as i32, "{merged:?}"); + let finding = merged + .validation + .iter() + .find(|i| i.code == "DOMAIN_SLUG_MISMATCH") + .unwrap_or_else(|| panic!("dry_run={dry_run}: {:?}", merged.validation)); + assert_eq!( + finding.severity, + pb::validation_issue::Severity::Error as i32 + ); + } + assert!(exists(&svc, pb::EntityKind::Domain, id("sw-me", "other")).await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn merging_a_deletion_reports_the_finding_it_leaves_behind() { + let branch = "strategic-delete"; + let svc = merge_world(branch).await; + realized_chain(&svc, "sw-md-ps", "sw-md").await; + svc.delete_entity(on_branch( + branch, + delete_request( + pb::EntityKind::BoundedContext, + id("sw-md", "sw-md"), + Mode::FailIfReferenced, + ), + )) + .await + .unwrap(); + + for dry_run in [true, false] { + let merged = svc + .merge_branch(Request::new(pb::MergeBranchRequest { + name: branch.into(), + dry_run, + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + let expected = if dry_run { + pb::merge_branch_response::Status::Validated + } else { + pb::merge_branch_response::Status::Applied + }; + assert_eq!(merged.status, expected as i32, "{merged:?}"); + assert_eq!( + count(&merged.validation, "SUBDOMAIN_UNREALIZED"), + 1, + "dry_run={dry_run}: {:?}", + merged.validation + ); + } + assert!(!exists(&svc, pb::EntityKind::BoundedContext, id("sw-md", "sw-md")).await); +} From 8b9d8f6cc83fa6fb10ed18a1460cc6578f1f0422 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 08:04:28 -0400 Subject: [PATCH 2/5] fix(server): keep the mixed classification hint spell-checkable Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-atlas-server/src/validation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsworkspace/crates/trogon-atlas-server/src/validation.rs b/rsworkspace/crates/trogon-atlas-server/src/validation.rs index c4597f13..f5d72ee5 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/validation.rs @@ -429,7 +429,7 @@ pub fn rule_catalog() -> Vec { "The subdomain referenced by BoundedContext.realizes is not in the store.", BoundedContext, "realizes", "strategic"), r("REALIZES_MIXED_CLASSIFICATIONS", Warning, "Bounded context realizes subdomains of different classifications", - "One boundary carries two investment levels, so one of them is mis-budgeted. Split the context along the classification line.", + "One boundary carries two investment levels, so one of them is funded at the wrong level. Split the context along the classification line.", BoundedContext, "realizes", "strategic"), r("REALIZES_MULTIPLE", Info, "Bounded context realizes multiple subdomains", "A BoundedContext realizing several subdomains of one classification is allowed but unusual; surfaced for awareness.", @@ -2173,7 +2173,7 @@ pub fn validate_scenarios(entity: &pb::Entity, all: &[StoredEntity]) -> Vec Date: Wed, 21 Oct 2026 08:19:01 -0400 Subject: [PATCH 3/5] fix(server): judge strategic writes against what other replicas already stored Signed-off-by: Yordis Prieto --- .../crates/trogon-atlas-server/src/service.rs | 15 +-- .../tests/strategic_write_validation.rs | 94 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 88b6d31d..b3625de3 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -4474,6 +4474,9 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // is transiently dangling and rejecting it would make the model // impossible to build one call at a time. They ride the response. if is_strategic_kind(kind) { + if branch.is_none() { + self.invalidate_snapshot().await; + } let all = self.snapshot_for(branch.as_deref(), &vis).await?; let world = world_after(&all, &[(kind, &id, Some(&entity))]); scenario_issues.extend( @@ -5201,10 +5204,11 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // the op that caused it when that entity is not in the batch. let mut per_op_validation: Vec> = std::iter::repeat_with(Vec::new).take(metas.len()).collect(); - if has_strategic_puts || has_strategic_deletes { - if branch.is_none() && !has_slice_puts { - self.invalidate_snapshot().await; - } + let has_strategic_ops = has_strategic_puts || has_strategic_deletes; + if branch.is_none() && (has_strategic_ops || has_slice_puts) { + self.invalidate_snapshot().await; + } + if has_strategic_ops { let base = self.snapshot_for(branch.as_deref(), &vis).await?; let world_changes: Vec<(pb::EntityKind, &pb::Id, Option<&pb::Entity>)> = metas .iter() @@ -5253,9 +5257,6 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl } } if has_slice_puts { - if branch.is_none() { - self.invalidate_snapshot().await; - } let pre_all_for_validation = self.snapshot_for(branch.as_deref(), &vis).await?; let mut validation_ctx: Vec = (*pre_all_for_validation).clone(); validation_ctx.extend(metas.iter().filter(|m| m.is_put).filter_map(|m| { diff --git a/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs b/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs index c6879cea..24c2ebaf 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/strategic_write_validation.rs @@ -294,6 +294,65 @@ async fn an_applied_batch_carries_the_same_findings_and_persists() { assert!(exists(&svc, pb::EntityKind::BoundedContext, id("sw-app", "sw-app")).await); } +fn command_slice(ns: &str, slug: &str) -> pb::Entity { + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::CommandSlice(pb::CommandSlice { + id: Some(id(ns, slug)), + ..Default::default() + })), + } +} + +/// Two services over one store, the first with a warm baseline cache, as +/// two server replicas would be after the first one served a read. +async fn replicas(warm_ns: &str) -> (EventModelServiceImpl, EventModelServiceImpl) { + let nats = trogon_atlas_testsupport::shared().await; + let store = Arc::new(nats.store().await); + let replica = EventModelServiceImpl::try_new(store.clone(), ServerMetrics::default()).unwrap(); + let other = EventModelServiceImpl::try_new(store, ServerMetrics::default()).unwrap(); + let warm = batch(&replica, vec![context(warm_ns, &[])], true).await; + assert_eq!( + warm.status, + pb::batch_mutate_response::Status::Validated as i32, + "{warm:?}" + ); + (replica, other) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_batch_with_slices_sees_strategic_writes_from_another_replica() { + let (replica, other) = replicas("sw-rep").await; + put(&other, domain("sw-rep-ps", "sw-rep-ps")).await; + put( + &other, + subdomain("sw-rep-ps", "ordering", Classification::Core), + ) + .await; + + let resp = batch( + &replica, + vec![ + context("sw-rep", &[("sw-rep-ps", "ordering")]), + command_slice("sw-rep", "checkout"), + ], + true, + ) + .await; + + assert_eq!( + resp.status, + pb::batch_mutate_response::Status::Validated as i32, + "{resp:?}" + ); + let context_issues = op_validation(&resp, 0); + assert_eq!( + count(context_issues, "REALIZES_DANGLING"), + 0, + "{context_issues:?}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn a_batch_finding_about_a_stored_entity_rides_the_op_that_caused_it() { let svc = svc().await; @@ -832,3 +891,38 @@ async fn merging_a_deletion_reports_the_finding_it_leaves_behind() { } assert!(!exists(&svc, pb::EntityKind::BoundedContext, id("sw-md", "sw-md")).await); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_put_sees_strategic_writes_from_another_replica() { + let (replica, other) = replicas("sw-rep-put").await; + put(&other, domain("sw-rep-put-ps", "sw-rep-put-ps")).await; + put( + &other, + subdomain("sw-rep-put-ps", "ordering", Classification::Core), + ) + .await; + + let issues = put( + &replica, + context("sw-rep-put", &[("sw-rep-put-ps", "ordering")]), + ) + .await; + + assert_eq!(count(&issues, "REALIZES_DANGLING"), 0, "{issues:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_delete_sees_strategic_writes_from_another_replica() { + let (replica, other) = replicas("sw-rep-del-warm").await; + realized_chain(&other, "sw-rep-del-ps", "sw-rep-del").await; + + let issues = delete( + &replica, + pb::EntityKind::Subdomain, + id("sw-rep-del-ps", "ordering"), + Mode::DryRun, + ) + .await; + + assert_eq!(count(&issues, "REALIZES_DANGLING"), 1, "{issues:?}"); +} From c9aa23a58bdf83f677632b71575b26d5a69b26e9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 08:29:33 -0400 Subject: [PATCH 4/5] fix(cli): let an apply that landed succeed despite strategic errors Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-atlas-cli/README.md | 5 +- .../crates/trogon-atlas-cli/src/main.rs | 24 +++------- .../tests/branch_cli_integration.rs | 46 +++++++++++++++++++ 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/rsworkspace/crates/trogon-atlas-cli/README.md b/rsworkspace/crates/trogon-atlas-cli/README.md index 07797d88..bb064b8d 100644 --- a/rsworkspace/crates/trogon-atlas-cli/README.md +++ b/rsworkspace/crates/trogon-atlas-cli/README.md @@ -46,8 +46,9 @@ event model: the model-level rules (storyboard entries, projection order, field provenance) and the strategic chain of the model's namespace, meaning its BoundedContext, the Subdomains it realizes, their Domains, and any Domain declared in the namespace. `apply --dry-run` returns only the request-time -findings for each write, strategic warnings included (they never block the -apply), so run `validate` after an apply to see the whole-model findings. +findings for each write, strategic ones included. They never block the +apply at any severity, so an apply that succeeds exits `0`; run `validate` +after an apply to see the whole-model findings. With `--branch`, it validates the branch's view of the model. It exits `1` when any finding is an error and `6` when the model does not exist. diff --git a/rsworkspace/crates/trogon-atlas-cli/src/main.rs b/rsworkspace/crates/trogon-atlas-cli/src/main.rs index a47b16c8..1a3ddf0c 100644 --- a/rsworkspace/crates/trogon-atlas-cli/src/main.rs +++ b/rsworkspace/crates/trogon-atlas-cli/src/main.rs @@ -81,8 +81,9 @@ enum Cmd { /// Run server-side validation for every change without persisting. /// Reports the request-time findings for each write, including the /// strategic ones (BoundedContext, Domain, Subdomain rules), which - /// are warnings and never block the apply. Findings that need the - /// whole model in view come from `validate` and `validate-project`. + /// never block the apply at any severity, so it still exits 0. + /// Findings that need the whole model in view come from `validate` + /// and `validate-project`. #[arg(long)] dry_run: bool, /// Idempotency key for the batch this apply sends; omit to mint a @@ -467,8 +468,8 @@ fn summary_json(counts: branch::SeverityCounts) -> Value { } /// Exit code for a command whose output reports something short of success -/// without being an error: drift a diff found, issues apply reported, a -/// branch's remaining conflicts. Also used by `trogon-atlas fmt --check`. +/// without being an error: drift a diff found, a branch's remaining +/// conflicts. Also used by `trogon-atlas fmt --check`. const EXIT_FINDINGS: u8 = 1; /// A clap usage error (missing/conflicting flags) exits 2 on its own, @@ -760,24 +761,11 @@ async fn run( } } } - let has_errors = outcome.issues.iter().any(|issue| { - matches!( - pb::validation_issue::Severity::try_from(issue.severity), - Ok(pb::validation_issue::Severity::Error) - ) - }); let data = json!({ "lines": outcome.lines, "issues": outcome.issues.iter().map(apply::issue_to_json).collect::>(), }); - Ok(( - if has_errors { - ExitCode::from(EXIT_FINDINGS) - } else { - ExitCode::SUCCESS - }, - Some(data), - )) + Ok((ExitCode::SUCCESS, Some(data))) } Cmd::Diff { filenames } => { let files = manifest::read_paths(&filenames)?; diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs index 23c0af26..289f9490 100644 --- a/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs +++ b/rsworkspace/crates/trogon-atlas-cli/tests/branch_cli_integration.rs @@ -438,6 +438,52 @@ fn tempfile_dir() -> tempfile::TempDir { .unwrap() } +// A BoundedContext realizing a Subdomain that is not stored yet lands with +// an Error-severity REALIZES_DANGLING finding; the write succeeded, so apply +// exits 0 and still reports the finding. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trogon_atlas_apply_exits_zero_when_only_strategic_errors_ride_a_landed_write() { + let endpoint = start_server().await; + let ns = "cli-apply-strategic-error"; + let dir = tempfile_dir(); + let manifest_path = dir.path().join("context.yaml"); + std::fs::write( + &manifest_path, + format!( + r" +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: BoundedContext +metadata: {{ namespace: {ns}, name: {ns} }} +spec: + title: Checkout + realizes: + - id: {{ namespace: {ns}, slug: checkout, version: '1' }} +" + ), + ) + .unwrap(); + + let applied = trogon_atlas( + &endpoint, + &[ + "--format", + "json", + "apply", + "-f", + manifest_path.to_str().unwrap(), + ], + ); + assert_eq!(applied.status.code(), Some(0), "{applied:?}"); + let doc: serde_json::Value = serde_json::from_str(&applied.stdout).unwrap(); + let dangling = doc["data"]["issues"] + .as_array() + .unwrap() + .iter() + .find(|issue| issue["code"] == "REALIZES_DANGLING") + .unwrap_or_else(|| panic!("no REALIZES_DANGLING in {doc}")); + assert_eq!(dangling["severity"], "error", "{doc}"); +} + // An applied merge carries strategic findings (non-blocking at any severity). // Depends on the server returning them on STATUS_APPLIED. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From b5c60a21dc21b38e738c866e273553a7396e6a5b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 22 Oct 2026 15:28:02 -0400 Subject: [PATCH 5/5] fix(examples): say where the shopper's earlier order status comes from Signed-off-by: Yordis Prieto --- examples/manifests/acme-fulfillment/storyboard.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/manifests/acme-fulfillment/storyboard.yaml b/examples/manifests/acme-fulfillment/storyboard.yaml index ded9efbb..464a2ca4 100644 --- a/examples/manifests/acme-fulfillment/storyboard.yaml +++ b/examples/manifests/acme-fulfillment/storyboard.yaml @@ -4,7 +4,7 @@ metadata: name: fulfillment-flow namespace: acme-fulfillment spec: - doc: A paid order is dispatched to the carrier and confirmed delivered. It starts from the fulfillment queue that the storefront order.paid event populates, so that projection is upstream preparation and not a step here. + doc: A paid order is dispatched to the carrier and confirmed delivered. It starts from the fulfillment queue that the storefront order.paid event populates, so that projection is upstream preparation and not a step here. The order-status rows for placement and payment arrive the same way, through its subscription to the storefront order.placed and order.paid events. entry: automation: processor: