diff --git a/rsworkspace/crates/trogon-atlas-client/tests/export_integration.rs b/rsworkspace/crates/trogon-atlas-client/tests/export_integration.rs index 8e3ae48f..f6d76396 100644 --- a/rsworkspace/crates/trogon-atlas-client/tests/export_integration.rs +++ b/rsworkspace/crates/trogon-atlas-client/tests/export_integration.rs @@ -626,9 +626,11 @@ async fn export_reports_skipped_label_for_every_entity_kind() { }), ), ( - "bounded-context", + // The server rejects a context whose slug differs from its + // namespace, so this entry is matched by its kind prefix. + "bounded_context:", K::BoundedContext(pb::BoundedContext { - id: Some(id(ns, "bounded-context")), + id: Some(id(ns, ns)), metadata: poisoned_metadata(), ..Default::default() }), diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 81247db5..77909eb4 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -4026,6 +4026,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl } validate_entity_content(&entity)?; check_project_namespace_conflict(&entity)?; + check_context_slug(&entity)?; let type_library = self .type_libraries .admit(&*self.store, &entity, branch.as_deref()) @@ -4589,6 +4590,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl let id = entity_id(&entity)?.clone(); validate_entity_content(&entity)?; check_project_namespace_conflict(&entity)?; + check_context_slug(&entity)?; if is_slice_kind(kind) { has_slice_puts = true; } @@ -8015,6 +8017,25 @@ fn check_project_namespace_conflict(entity: &pb::Entity) -> Result<(), Status> { Ok(()) } +/// 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. +#[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 { + return Ok(()); + }; + match validation::context_slug_mismatch(bc) { + Some(issue) => Err(Status::invalid_argument(format!( + "{}: {}", + issue.code, issue.message + ))), + None => Ok(()), + } +} + /// Classify an LLM error into a stable short reason code for client visibility. /// Keeps internal error details server-side; only the category crosses the wire. fn llm_fallback_reason(err: &anyhow::Error) -> &'static str { diff --git a/rsworkspace/crates/trogon-atlas-server/src/validation.rs b/rsworkspace/crates/trogon-atlas-server/src/validation.rs index 04bf98fb..44ec7989 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/validation.rs @@ -1038,8 +1038,32 @@ fn entity_metadata(entity: &pb::Entity) -> &[prost_types::Any] { } } +/// `CONTEXT_SLUG_MISMATCH`: the context's root document names its boundary, +/// so its slug must equal its namespace (Decision #27). That keeps one +/// context per namespace. +pub(crate) fn context_slug_mismatch(bc: &pb::BoundedContext) -> Option { + let id = bc.id.as_ref()?; + if id.slug == id.namespace { + return None; + } + Some(pb::ValidationIssue { + severity: pb::validation_issue::Severity::Error as i32, + code: "CONTEXT_SLUG_MISMATCH".into(), + message: format!( + "bounded context slug {:?} must equal its namespace {:?}; the context IS the namespace, and slug == namespace keeps it one per context", + id.slug, id.namespace, + ), + subject: Some(pb::EntityRef { + kind: pb::EntityKind::BoundedContext as i32, + id: Some(id.clone()), + }), + subject_field: "id.slug".into(), + ..Default::default() + }) +} + // Store-level hygiene that doesn't fit inside a single EventModel scope: -// project identity (namespace == slug), Domains declaring their project, +// project and context identity (namespace == slug), Domains declaring their project, // and cross-project collisions on BoundedContext ids. Called from // ValidateProject. Per-model ValidateEventModel skips this because the // concern is global; otherwise the same finding would be emitted once @@ -1079,6 +1103,12 @@ pub fn validate_store(all: &[StoredEntity]) -> Vec { } } + for se in all { + if let Some(pb::entity::Kind::BoundedContext(bc)) = se.entity.kind.as_ref() { + issues.extend(context_slug_mismatch(bc)); + } + } + // DOMAIN_MISSING_PROJECT: every Domain should declare its Project // so cross-project ownership is unambiguous. Build a Domain -> Project // map at the same time so the BC_PROJECT_COLLISION pass can use it. @@ -1866,21 +1896,7 @@ pub fn validate_scenarios(entity: &pb::Entity, all: &[StoredEntity]) -> Vec { - if let Some(id) = bc.id.as_ref() { - if id.slug != id.namespace { - issues.push(pb::ValidationIssue { - severity: pb::validation_issue::Severity::Error as i32, - code: "CONTEXT_SLUG_MISMATCH".into(), - message: format!( - "bounded context slug {:?} must equal its namespace {:?}; the context IS the namespace, and slug == namespace keeps it one per context", - id.slug, id.namespace, - ), - subject: subject.clone(), - subject_field: "id.slug".into(), - ..Default::default() - }); - } - } + issues.extend(context_slug_mismatch(bc)); for (i, r) in bc.realizes.iter().enumerate() { let Some(sid) = r.id.as_ref() else { issues.push(issue_error( @@ -7453,6 +7469,38 @@ mod context_root_tests { 1 ); } + + fn stored(entity: pb::Entity) -> StoredEntity { + StoredEntity { + entity, + etag: "1".into(), + } + } + + #[test] + fn store_flags_only_the_diverging_context() { + let all = vec![ + stored(bc("acme-probe", "acme-probe")), + stored(bc("acme-probe", "billing")), + ]; + let flagged: Vec<_> = validate_store(&all) + .into_iter() + .filter(|i| i.code == "CONTEXT_SLUG_MISMATCH") + .collect(); + assert_eq!(flagged.len(), 1, "{flagged:?}"); + assert_eq!( + flagged[0] + .subject + .as_ref() + .and_then(|s| s.id.as_ref()) + .map(|id| id.slug.as_str()), + Some("billing"), + ); + assert_eq!( + flagged[0].severity, + pb::validation_issue::Severity::Error as i32 + ); + } } #[cfg(test)] diff --git a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs index faf723f1..ff2bfdce 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs @@ -3516,6 +3516,80 @@ async fn batch_mutate_domain_cross_project_namespace_rejected() { assert_eq!(resp.code(), tonic::Code::FailedPrecondition); } +fn make_bounded_context(ns: &str, slug: &str) -> pb::Entity { + pb::Entity { + system: None, + kind: Some(pb::entity::Kind::BoundedContext(pb::BoundedContext { + id: Some(id(ns, slug, 1)), + title: format!("{ns}/{slug}"), + ..Default::default() + })), + } +} + +fn put_request(entity: pb::Entity) -> pb::PutEntityRequest { + pb::PutEntityRequest { + operation_id: String::new(), + entity: Some(entity), + create_only: false, + if_match: String::new(), + validate_only: false, + force: false, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn put_entity_bounded_context_slug_mismatch_rejected() { + let svc = svc().await; + let err = svc + .put_entity(Request::new(put_request(make_bounded_context( + "acme-probe", + "billing", + )))) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!( + err.message().contains("CONTEXT_SLUG_MISMATCH"), + "{}", + err.message() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn put_entity_bounded_context_slug_matching_namespace_accepted() { + let svc = svc().await; + svc.put_entity(Request::new(put_request(make_bounded_context( + "acme-root-bc", + "acme-root-bc", + )))) + .await + .expect("bounded context whose slug equals its namespace must be accepted"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn batch_mutate_bounded_context_slug_mismatch_rejected() { + let svc = svc().await; + let err = svc + .batch_mutate(Request::new(pb::BatchMutateRequest { + operation_id: String::new(), + ops: vec![pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(put_request( + make_bounded_context("acme-probe-batch", "billing"), + ))), + }], + validate_only: false, + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!( + err.message().contains("CONTEXT_SLUG_MISMATCH"), + "{}", + err.message() + ); +} + // ===== principal authorship tests (P1) ===== #[tokio::test(flavor = "multi_thread")]