Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}),
Expand Down
21 changes: 21 additions & 0 deletions rsworkspace/crates/trogon-atlas-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 64 additions & 16 deletions rsworkspace/crates/trogon-atlas-server/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<pb::ValidationIssue> {
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
Expand Down Expand Up @@ -1079,6 +1103,12 @@ pub fn validate_store(all: &[StoredEntity]) -> Vec<pb::ValidationIssue> {
}
}

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.
Expand Down Expand Up @@ -1866,21 +1896,7 @@ pub fn validate_scenarios(entity: &pb::Entity, all: &[StoredEntity]) -> Vec<pb::
// The context's root document names its boundary: slug == namespace
// (Decision #27), which also makes it naturally unique per context.
Some(pb::entity::Kind::BoundedContext(bc)) => {
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(
Expand Down Expand Up @@ -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)]
Expand Down
74 changes: 74 additions & 0 deletions rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading