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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Look up exact behavior:
Resource contracts and acceptance:

- [Architecture resource coverage](reference/architecture-resource-coverage.md)
- [Domain-scoped listing](reference/domain-scoped-listing.md)
- [Channel and DataStore contracts](reference/channel-and-datastore-contracts.md)
- [Channel and DataStore acceptance fixture](reference/channel-and-datastore-acceptance.md)
- [Specification and API contracts](reference/specification-and-api-contracts.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture-resource-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ version strings do not replace these selectors. See

| Area | Existing representation | Remaining boundary |
| --- | --- | --- |
| Strategic membership | Domain project, Subdomain domain and context realizations are native references. | Arbitrary domain nesting and shared authored system/service membership have no dedicated fields. Domain listing derives membership from namespace and one realized subdomain; its maps omit versions. It does not prove exact-version or multi-domain membership. |
| Strategic membership | Domain project, Subdomain domain and context realizations are native references. [Domain-scoped listing](domain-scoped-listing.md) preserves exact strategic versions and considers every realization; ordinary entity scope remains namespace-derived. | Arbitrary domain nesting and shared authored system/service membership have no dedicated fields. Listing does not establish authored membership, change project-validation scope or add a Domain version selector to Studio. |
| System and service relationships | Component members can select other Components and ExternalSystems, with ordinary reference discovery. | Dedicated grouping levels, labeled directed system relationships and system-level actor interactions require a contract decision. Containment is Component-owned; ExternalSystem has no members field, so external-system-owned membership also remains a contract decision. |
| Communication | API roles/providers/usages, Processor calls and Channel bindings to actual observations are supported. | General authored send/receive and conditional-trigger declarations before behavioral mapping have no native relationship contract. Retained source declarations do not establish executable paths. |
| Metadata and agents | Registered annotations include links, code repository/path/symbol/ref provenance, tags and lifecycle status. | Lifecycle effective dates and agent model/tool declarations need dedicated contracts. Arbitrary metadata references are not recursively indexed. See [annotations](../../proto/trogonatlas/annotation/v1alpha1/annotations.proto). |
Expand Down
77 changes: 77 additions & 0 deletions docs/reference/domain-scoped-listing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Domain-scoped listing

`ListEntitiesByDomain` lists native entities through their declared strategic
references. Its [request](../../proto/trogonatlas/api/eventmodel/v1alpha1/service.proto)
selects a Domain ID, optional entity kinds, pagination and
`latest_versions_only`. This is a query over the existing strategic model,
not a new system-membership or authorization mechanism.

## Exact strategic selection

Domain and Subdomain references use namespace, slug and numeric version.
Selecting a Domain does not select another version of that Domain. A context
realizing a Subdomain version does not fall back to a different version of
that Subdomain.

| Returned entity | Membership rule |
| --- | --- |
| Domain | Its complete ID matches the requested Domain ID. |
| Subdomain | Its declared Domain reference matches the complete requested ID. |
| BoundedContext | At least one of its own realized Subdomain references resolves to a stored Subdomain declaring the requested Domain ID. |
| Other native entity | Its namespace has a BoundedContext realization chain to the requested Domain ID. |

Every declared realization is considered. A missing Subdomain does not suppress
a different valid realization. Repeated references and multiple matching paths
do not duplicate returned entities. A context realizing subdomains in different
domains may contribute to each domain's result.

Each BoundedContext version is checked against its own references. Other
entities retain namespace-derived scope across the visible context versions;
their numeric versions are not matched to a context's numeric version.
This scope does not establish Component containment, explicit service
membership, ownership or execution.

## Filtering and pagination

The optional kind filter restricts returned entity kinds. With
`latest_versions_only`, the result retains the greatest numeric version of each
matching `(kind, namespace, slug)` before pagination. Exact strategic reference
resolution still uses the pinned versions, including older Subdomain versions.
The flag does not retarget references or replace an explicitly selected Domain
with a newer Domain version.

Pages use native `(kind, namespace, slug, version)` ordering. A page token
continues the same query; keep the Domain ID, filters, branch and authorization
context unchanged. Independent requests do not establish a historical
transaction snapshot if the model changes between pages.

## Branch and client scope

Queries use the current authorized branch view. Branch-only entities appear
only in that branch, and hidden namespaces cannot contribute returned entities
or realization paths.

Branch visibility is checked independently of namespace visibility. An
inaccessible branch returns `NOT_FOUND`, even when it contains entities in a
namespace the caller may otherwise read.

Resolution uses the existing capped model snapshot. An omitted intermediate
record can prevent a realization chain from resolving. Empty results or an
exhausted page token therefore do not establish whole-store completeness.

MCP `list_entities_by_domain` requires an explicit positive Domain version and
defaults `latest_versions_only` to true. Studio's current `/api/by-domain`
route selects Domain version `1`; this backend query contract does not add a
version selector to that route. `ValidateProject` has a separate scope path
and is unchanged by this correction.

## Executable coverage

The [public transport regressions](../../rsworkspace/crates/trogon-atlas-server/tests/domain_query_membership.rs)
exercise exact Domain/Subdomain versions, context-specific realizations,
multiple and dangling realization paths, deduplication, latest matching
versions before filtered pagination, branch overlays, foreign-branch refusal
and owner visibility.
They use supported writes and authenticated gRPC against a real NATS Store.
Their presence alone is not a recorded passing run or proof of every
strategic-model acceptance case.
72 changes: 60 additions & 12 deletions rsworkspace/crates/trogon-atlas-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7501,6 +7501,13 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
) -> Result<Response<pb::ListEntitiesByDomainResponse>, Status> {
let vis = Self::visibility_of(&req);
let branch = Self::branch_from_metadata(&req)?;
let principal = Self::principal_from_metadata(&req);
if branch
.as_deref()
.is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch))
{
return Err(Status::not_found("branch not found"));
}
let req = req.into_inner();
let dom_id = req
.domain_id
Expand All @@ -7515,8 +7522,37 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl

let all = self.snapshot_for(branch.as_deref(), &vis).await?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let (subdomain_domain, ns_to_dom) = build_domain_maps(all.as_ref());
let target = (dom_id.namespace.clone(), dom_id.slug.clone());
let target = pb::IdKey::new(&dom_id);
let subdomains: std::collections::HashSet<pb::IdKey> = all
.iter()
.filter_map(|se| {
let Some(pb::entity::Kind::Subdomain(subdomain)) = se.entity.kind.as_ref() else {
return None;
};
let domain_id = subdomain.domain.as_ref()?.id.as_ref()?;
(pb::IdKey::new(domain_id) == target)
.then(|| subdomain.id.as_ref().map(pb::IdKey::new))
.flatten()
})
.collect();
let contexts: std::collections::HashSet<pb::IdKey> = all
.iter()
.filter_map(|se| {
let Some(pb::entity::Kind::BoundedContext(context)) = se.entity.kind.as_ref()
else {
return None;
};
context
.realizes
.iter()
.filter_map(|reference| reference.id.as_ref())
.any(|id| subdomains.contains(&pb::IdKey::new(id)))
.then(|| context.id.as_ref().map(pb::IdKey::new))
.flatten()
})
.collect();
let namespaces: std::collections::HashSet<&str> =
contexts.iter().map(|id| id.namespace.as_str()).collect();

let mut matched: Vec<pb::Entity> = Vec::new();
for se in all.iter() {
Expand All @@ -7529,22 +7565,34 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
if !kinds.is_empty() && !kinds.contains(&(k as i32)) {
continue;
}
// The Domain itself, its Subdomains, and its BCs always belong;
// everything else has to chain back via the BC namespace.
let belongs = match k {
pb::EntityKind::Domain => id.namespace == target.0 && id.slug == target.1,
pb::EntityKind::Subdomain => subdomain_domain
.get(&(id.namespace.clone(), id.slug.clone()))
.is_some_and(|d| d == &target),
pb::EntityKind::BoundedContext => {
ns_to_dom.get(&id.namespace).is_some_and(|d| d == &target)
}
_ => ns_to_dom.get(&id.namespace).is_some_and(|d| d == &target),
pb::EntityKind::Domain => pb::IdKey::new(id) == target,
pb::EntityKind::Subdomain => subdomains.contains(&pb::IdKey::new(id)),
pb::EntityKind::BoundedContext => contexts.contains(&pb::IdKey::new(id)),
_ => namespaces.contains(id.namespace.as_str()),
};
if belongs {
matched.push(se.entity.clone());
}
}
if req.latest_versions_only {
let mut latest = std::collections::HashMap::new();
for entity in &matched {
let (Ok(kind), Ok(id)) = (entity_kind(entity), entity_id(entity)) else {
continue;
};
let version = latest
.entry((kind, id.namespace.clone(), id.slug.clone()))
.or_insert(id.version);
*version = (*version).max(id.version);
}
matched.retain(|entity| {
let (Ok(kind), Ok(id)) = (entity_kind(entity), entity_id(entity)) else {
return false;
};
latest.get(&(kind, id.namespace.clone(), id.slug.clone())) == Some(&id.version)
});
}
let total = matched.len();
let entities: Vec<pb::Entity> = matched.into_iter().skip(offset).take(page_size).collect();
let next_offset = offset + entities.len();
Expand Down
Loading
Loading