Skip to content

Ontology under-represents composite identity for tenant-scoped resources (TenantClusterVariable) #293

@jwulf

Description

@jwulf

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}):

"x-semantic-establishes": {
  "kind": "TenantClusterVariable",
  "identifiedBy": [
    { "in": "path", "name": "tenantId", "semanticType": "TenantId" },
    { "in": "body", "name": "name",     "semanticType": "ClusterVariableName" }
  ]
},
"x-semantic-requires": { "kind": "Tenant", "bind": { "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 as Edge records in configs/camunda-oca/ontology/edges.json, and Edge.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):

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.identifiersentity-kinds.<this>.description-parsededges.* 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)

  1. 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.
  2. 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:
    "identifiers": ["ClusterVariableName"],
    "compositeIdentity": ["TenantId", "ClusterVariableName"]
  3. 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.
  4. 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)

  • Decide between options 1–4 (or another approach) with a single-line rationale.
  • Update path-analyser/src/ontology/entityKindSchema.ts and configs/camunda-oca/ontology/entity-kinds.json accordingly.
  • npm run build:ontology regenerates ontology/vocabulary/entity-kinds.schema.json.
  • L3 invariant in configs/camunda-oca/regression-invariants.test.ts: every operation whose x-semantic-establishes.identifiedBy[] has ≥2 entries is either (a) modelled as an Edge with matching identifiedBy, or (b) modelled as an EntityKind whose composite-identity representation (whatever shape we pick) covers all spec identifiers.
  • Class-scoped: the invariant must reject reintroduction of the asymmetry for any future kind, not just TenantClusterVariable.
  • No planner output drift expected (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

  • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions