Summary
TenantClusterVariable is the only OCA entity kind whose canonical identifier is a 2-tuple where one element (TenantId) is owned by a different entity kind (Tenant). The spec captures the composite identity correctly; the ontology cannot, because EntityKind.identifiers[] is constrained by a single-owner rule. The tenant half of the identity is reified as a requires: Tenant relationship instead.
This is a deliberate workaround documented in the entity's description. It is not currently a bug — the planner has everything it needs from the two signals combined — but it creates an asymmetry with the Edge shape that's worth tracking before more resources of this kind appear.
Findings (full sweep of x-semantic-establishes.identifiedBy[] with ≥2 entries)
13 operations in the OCA spec declare composite identifiers. They split into two structural patterns:
Pattern A — Tenant-scoped resource (1 op)
| Operation |
Establishes |
Identifiers in spec |
Ontology shape |
Ontology identifiers |
createTenantClusterVariable |
TenantClusterVariable |
TenantId + ClusterVariableName |
EntityKind |
ClusterVariableName only |
Spec (spec/camunda-oca/bundled/rest-api.bundle.json, POST /cluster-variables/tenants/{tenantId}):
Ontology (configs/camunda-oca/ontology/entity-kinds.json):
Pattern B — Membership edges (12 ops, all clean)
All assign*To* operations. These are correctly modelled as Edge records in configs/camunda-oca/ontology/edges.json, and Edge.identifiedBy[] accepts a full composite:
Full list (no gap on any of these):
Spec kind |
Identifiers |
RoleUserMembership |
RoleId + Username |
RoleClientMembership |
RoleId + ClientId |
RoleGroupMembership |
RoleId + GroupId |
RoleMappingRuleMembership |
RoleId + MappingRuleId |
GroupUserMembership |
GroupId + Username |
GroupClientMembership |
GroupId + ClientId |
GroupMappingRuleMembership |
GroupId + MappingRuleId |
TenantUserMembership |
TenantId + Username |
TenantClientMembership |
TenantId + ClientId |
TenantGroupMembership |
TenantId + GroupId |
TenantRoleMembership |
TenantId + RoleId |
TenantMappingRuleMembership |
TenantId + MappingRuleId |
The asymmetry
| Shape |
Supports composite identifiedBy[]? |
Why |
Edge |
✅ |
Edges are inherently between two owned entities; the composite is the edge's primary key. |
EntityKind |
❌ |
Single-owner rule on semantic types: each SemanticType may be listed in at most one EntityKind.identifiers[], so edge-derivation can unambiguously look up the owning kind. |
TenantClusterVariable is structurally an EntityKind (it has its own lifecycle: create/get/update/delete with a body carrying value) and not an Edge (it's not just an associative link between two pre-existing entities). So neither shape fits it cleanly today.
Why this matters
For the planner today: not at all. It uses x-semantic-requires: Tenant to chain the prereq and ClusterVariableName to mint the name, and the verification trace in PR #287 shows the emitted URL correctly populates both path segments.
For anyone reading the ontology to reconstruct identity:
- A uniform query like "list the canonical identifiers of
<kind>" gives the wrong answer for TenantClusterVariable (returns 1 of 2).
- The composite identity has to be reassembled by joining
entity-kinds.identifiers ∪ entity-kinds.<this>.description-parsed ∪ edges.* where endpoints.from == 'Tenant' — i.e. there's no machine-readable single source of truth.
For future spec growth: if OCA adds another tenant-scoped resource (likely candidates: tenant-scoped roles, tenant-scoped client secrets, tenant-scoped form/decision artifacts), each will hit the same asymmetry and the workaround scales linearly.
Options (no recommendation yet)
- Relax single-owner rule on
EntityKind.identifiers. Allow a semantic type to appear in multiple EntityKind.identifiers[]. Edge-derivation would need a tie-breaker (e.g. "the kind that establishes the type wins" or explicit owns: true marker). Largest change; touches path-analyser/src/ontology/edgeSchema.ts and the derivation logic.
- Add a non-authoritative
compositeIdentity field on EntityKind. Purely informational; edge-derivation continues to use identifiers[] only. Smallest change; cleanly captures the composite for any downstream consumer that wants it. TenantClusterVariable becomes:
- Introduce a
ScopedEntity shape (third shape alongside entity and edge). TenantClusterVariable declares shape: "scopedEntity", scopedBy: "Tenant", localIdentifier: "ClusterVariableName". Most expressive but introduces a new structural concept.
- Materialise the composite at export time. Ontology bundle (
generated/<config>/ontology-bundle.json) gets a derived canonicalIdentifiers field on each kind, computed from identifiers plus any single requires chain. Source files stay as-is; only the bundle changes.
Option 2 has the cleanest cost/benefit for the current scope (one entry). Option 4 is attractive if the goal is purely "let SPARQL/GraphDB consumers see the full identity" without changing the source contract.
Acceptance criteria (if/when we act)
Out of scope
- The 12 membership edges. They are already correctly modelled.
- Changing how
createTenantClusterVariable is planned, sequenced, or emitted.
- Any change to the upstream spec (
x-semantic-establishes already carries the full composite identity — the gap is in our ontology shape, not in the spec).
Refs
Summary
TenantClusterVariableis the only OCA entity kind whose canonical identifier is a 2-tuple where one element (TenantId) is owned by a different entity kind (Tenant). The spec captures the composite identity correctly; the ontology cannot, becauseEntityKind.identifiers[]is constrained by a single-owner rule. The tenant half of the identity is reified as arequires: Tenantrelationship instead.This is a deliberate workaround documented in the entity's
description. It is not currently a bug — the planner has everything it needs from the two signals combined — but it creates an asymmetry with theEdgeshape that's worth tracking before more resources of this kind appear.Findings (full sweep of
x-semantic-establishes.identifiedBy[]with ≥2 entries)13 operations in the OCA spec declare composite identifiers. They split into two structural patterns:
Pattern A — Tenant-scoped resource (1 op)
createTenantClusterVariableTenantClusterVariableTenantId+ClusterVariableNameClusterVariableNameonlySpec (
spec/camunda-oca/bundled/rest-api.bundle.json,POST /cluster-variables/tenants/{tenantId}):Ontology (
configs/camunda-oca/ontology/entity-kinds.json):{ "@type": "EntityKind", "name": "TenantClusterVariable", "shape": "entity", "identifiers": ["ClusterVariableName"], … "description": "… Only ClusterVariableName is listed in 'identifiers' here because TenantId is owned by the Tenant entity and must remain single-owner for the edge-derivation rule; the Tenant requirement of tenant-scoped operations is expressed via the tenantId path parameter." }Pattern B — Membership edges (12 ops, all clean)
All
assign*To*operations. These are correctly modelled asEdgerecords inconfigs/camunda-oca/ontology/edges.json, andEdge.identifiedBy[]accepts a full composite:{ "@type": "Edge", "name": "RoleUserMembership", "endpoints": { "from": "Role", "to": "User" }, "identifiedBy": ["RoleId", "Username"], … }Full list (no gap on any of these):
kindRoleUserMembershipRoleId+UsernameRoleClientMembershipRoleId+ClientIdRoleGroupMembershipRoleId+GroupIdRoleMappingRuleMembershipRoleId+MappingRuleIdGroupUserMembershipGroupId+UsernameGroupClientMembershipGroupId+ClientIdGroupMappingRuleMembershipGroupId+MappingRuleIdTenantUserMembershipTenantId+UsernameTenantClientMembershipTenantId+ClientIdTenantGroupMembershipTenantId+GroupIdTenantRoleMembershipTenantId+RoleIdTenantMappingRuleMembershipTenantId+MappingRuleIdThe asymmetry
identifiedBy[]?EdgeEntityKindSemanticTypemay be listed in at most oneEntityKind.identifiers[], so edge-derivation can unambiguously look up the owning kind.TenantClusterVariableis structurally anEntityKind(it has its own lifecycle: create/get/update/delete with a body carryingvalue) and not anEdge(it's not just an associative link between two pre-existing entities). So neither shape fits it cleanly today.Why this matters
For the planner today: not at all. It uses
x-semantic-requires: Tenantto chain the prereq andClusterVariableNameto mint the name, and the verification trace in PR #287 shows the emitted URL correctly populates both path segments.For anyone reading the ontology to reconstruct identity:
<kind>" gives the wrong answer forTenantClusterVariable(returns 1 of 2).entity-kinds.identifiers∪entity-kinds.<this>.description-parsed∪edges.* where endpoints.from == 'Tenant'— i.e. there's no machine-readable single source of truth.For future spec growth: if OCA adds another tenant-scoped resource (likely candidates: tenant-scoped roles, tenant-scoped client secrets, tenant-scoped form/decision artifacts), each will hit the same asymmetry and the workaround scales linearly.
Options (no recommendation yet)
EntityKind.identifiers. Allow a semantic type to appear in multipleEntityKind.identifiers[]. Edge-derivation would need a tie-breaker (e.g. "the kind that establishes the type wins" or explicitowns: truemarker). Largest change; touchespath-analyser/src/ontology/edgeSchema.tsand the derivation logic.compositeIdentityfield onEntityKind. Purely informational; edge-derivation continues to useidentifiers[]only. Smallest change; cleanly captures the composite for any downstream consumer that wants it.TenantClusterVariablebecomes:ScopedEntityshape (third shape alongsideentityandedge).TenantClusterVariabledeclaresshape: "scopedEntity",scopedBy: "Tenant",localIdentifier: "ClusterVariableName". Most expressive but introduces a new structural concept.generated/<config>/ontology-bundle.json) gets a derivedcanonicalIdentifiersfield on each kind, computed fromidentifiersplus any singlerequireschain. Source files stay as-is; only the bundle changes.Option 2 has the cleanest cost/benefit for the current scope (one entry). Option 4 is attractive if the goal is purely "let SPARQL/GraphDB consumers see the full identity" without changing the source contract.
Acceptance criteria (if/when we act)
path-analyser/src/ontology/entityKindSchema.tsandconfigs/camunda-oca/ontology/entity-kinds.jsonaccordingly.npm run build:ontologyregeneratesontology/vocabulary/entity-kinds.schema.json.configs/camunda-oca/regression-invariants.test.ts: every operation whosex-semantic-establishes.identifiedBy[]has ≥2 entries is either (a) modelled as anEdgewith matchingidentifiedBy, or (b) modelled as anEntityKindwhose composite-identity representation (whatever shape we pick) covers all spec identifiers.TenantClusterVariable.generated/camunda-oca/**byte-identical after regen) — this is an ontology-shape change, not a planning change. If drift occurs, justify in PR.Out of scope
createTenantClusterVariableis planned, sequenced, or emitted.x-semantic-establishesalready carries the full composite identity — the gap is in our ontology shape, not in the spec).Refs
spec/camunda-oca/bundled/rest-api.bundle.jsonPOST /cluster-variables/tenants/{tenantId}configs/camunda-oca/ontology/entity-kinds.json#L91-L101configs/camunda-oca/ontology/edges.jsonpath-analyser/src/ontology/edgeSchema.ts(TBox source) /ontology/vocabulary/edge.schema.json(committed JSON Schema)