Skip to content

Governed Vocabulary

client.vocabulary manages a tenant’s governed controlled vocabulary: concept schemes, concepts, definitions, labels in several languages, scheme memberships and their evidence. A change never edits the active vocabulary in place. It goes through a reviewable workflow:

create candidate ─▶ save revisions ─▶ assess ─▶ submit ─▶ review ─▶ approve ─▶ release ─▶ activate
(author) (author) (author) (author) (steward) (steward) (steward) (steward)
  • A candidate holds immutable revisions of a change set. Each revision has a contentHash; every decision cites the revision and its hash.
  • An assessment (vocabulary-assessment/v1) checks the revision and reports findings. Only a complete, passed assessment admits an approval.
  • Approval and release change neither the active release nor the knowledge base. A release is an immutable snapshot.
  • Activation swaps the tenant’s active-release pointer (compare-and-set) and writes the release’s records as Psi-terms of the com.kortexya.knowledge-organization plugin, in one transaction.

Nothing in the vocabulary creates a sort, a parent edge or a rule: a definition’s genus text stays text (definitionGuidance.parentEdgeCreated is always false).

  • The first-party knowledge-organization plugin is installed system-wide. client.vocabulary.status() reports it; every other route except getRequestOutcome refuses with 409 vocabulary_plugin_unavailable without it.
  • The tenant holds the authorization facts: the engine re-proves author_vocabulary, assess_vocabulary, review_vocabulary, approve_vocabulary, release_vocabulary and activate_vocabulary at each step. A deployment’s gateway provisions them.
  • Each mutation is made by the client’s authenticatedUser (user:<uuid>). An author may not review, reject or approve a revision they authored, so the author and the steward are two clients.
import { ReasoningLayerClient } from '@kortexya/reasoninglayer';
const config = {
baseUrl: 'https://platform.ovh.reasoninglayer.ai',
tenantId,
auth: { mode: 'cookie' as const },
};
const author = new ReasoningLayerClient({ ...config, authenticatedUser: `user:${authorId}` });
const steward = new ReasoningLayerClient({ ...config, authenticatedUser: `user:${stewardId}` });
const status = await author.vocabulary.status();
if (!status.plugin.available) throw new Error(`plugin: ${status.plugin.finding?.observed}`);

Every mutation takes a required idempotencyKey. The SDK never invents one: create it once per intended change, bind it to a variable before the call, and pass the same variable on every retry. A key created inside the call ({ idempotencyKey: crypto.randomUUID() }) changes on each attempt, so a retry becomes a new request.

  • An identical repeat answers the committed outcome with replayed: true and writes nothing.
  • The same key with a different request throws IdempotencyKeyReusedError (422).
  • A key is 1 to 128 characters of [A-Za-z0-9._:-].

Records refer to each other by id ({ by: 'record', recordId }) or, inside one request, by a client reference ({ by: 'client_ref', clientRef }). The response maps each client reference to the id the engine minted.

const createKey = crypto.randomUUID();
const revision = await author.vocabulary.createCandidate(
{
title: 'Production Approval Gate',
changeReason: 'Create the concept',
operations: [
{
op: 'upsert',
record: {
kind: 'scheme',
clientRef: 'mwg',
displayName: 'Manufacturing Workflow Governance',
purpose: 'Name the workflow-governance concepts.',
languages: [
{ language: 'en', obligation: 'required', rank: 1 },
{ language: 'fr', obligation: 'display_fallback', rank: 2 },
],
},
},
{ op: 'upsert', record: { kind: 'concept', clientRef: 'pag', status: 'accepted' } },
{
op: 'upsert',
record: {
kind: 'definition',
concept: { by: 'client_ref', clientRef: 'pag' },
language: 'en',
text: 'A workflow step that requires an authorized approval before a production deployment can proceed.',
genusText: 'Workflow Step',
differentiaText: 'requires authorization before a production deployment can proceed',
},
},
{
op: 'upsert',
record: {
kind: 'label_use',
concept: { by: 'client_ref', clientRef: 'pag' },
scheme: { by: 'client_ref', clientRef: 'mwg' },
text: 'Production Approval Gate',
language: 'en',
role: 'preferred',
},
},
{
op: 'upsert',
record: {
kind: 'membership',
concept: { by: 'client_ref', clientRef: 'pag' },
scheme: { by: 'client_ref', clientRef: 'mwg' },
status: 'accepted',
topConcept: true,
},
},
],
},
{ idempotencyKey: createKey },
);
const conceptId = revision.mintedIds.pag;

A later edit saves a new revision on the head: pass the head revision id (or the candidate’s ETag) as ifMatch. If someone saved in between, the call throws PreconditionFailedError and nothing is written; currentEtag names the new head.

const saveKey = crypto.randomUUID();
const next = await author.vocabulary.saveRevision(
revision.candidateId,
{ changeReason: 'Add an alternative label', operations },
{ idempotencyKey: saveKey, ifMatch: revision.revisionId },
);
// One key per intended change, bound before the call that uses it.
const keys = {
assess: crypto.randomUUID(),
submit: crypto.randomUUID(),
review: crypto.randomUUID(),
approve: crypto.randomUUID(),
};
const assessment = await author.vocabulary.assess(
next.candidateId,
{ revisionId: next.revisionId, contentHash: next.contentHash },
{ idempotencyKey: keys.assess },
);
await author.vocabulary.submit(
next.candidateId,
{ revisionId: next.revisionId, contentHash: next.contentHash },
{ idempotencyKey: keys.submit },
);
const cite = {
revisionId: next.revisionId,
contentHash: next.contentHash,
assessmentId: assessment.assessmentId,
};
await steward.vocabulary.review(
next.candidateId,
{
...cite,
disposition: 'endorse',
// Every review_required finding needs an accepted disposition, with a reason.
findingDispositions: assessment.findings
.filter((finding) => finding.severity === 'review_required')
.map((finding) => ({
findingId: finding.findingId,
disposition: 'accepted',
rationale: 'Kept distinct on purpose',
})),
},
{ idempotencyKey: keys.review },
);
const { decision: approval } = await steward.vocabulary.approve(next.candidateId, cite, {
idempotencyKey: keys.approve,
});

An incomplete assessment (it stopped at a record limit or a deadline) has outcome not_established and admits no approval (409 assessment_incomplete); a blocked one is refused 409 assessment_blocked.

const releaseKey = crypto.randomUUID();
const { release } = await steward.vocabulary.release(
next.candidateId,
{
revisionId: next.revisionId,
contentHash: next.contentHash,
approvalDecisionId: approval.decisionId,
},
{ idempotencyKey: releaseKey },
);
const active = await steward.vocabulary.active();
const activateKey = crypto.randomUUID();
const activation = await steward.vocabulary.activate(
release.releaseId,
{ expectedActiveReleaseId: active.releaseId, expectedActiveRevision: active.activeRevision },
{ idempotencyKey: activateKey },
);

The activation states the pointer the caller read. If another activation happened in between, it throws 409 active_release_changed and error.current names the new pointer.

Reads use the active release unless you pass a view: { view: 'release', releaseId } for an older release, or { view: 'candidate', revisionId } for a draft (view.draftNotActive is true; show it as “Draft — not active”).

const page = await author.vocabulary.listConcepts({ language: 'en' });
const { concept } = await author.vocabulary.getConcept(conceptId, { language: 'fr-CA' });
if (concept.displayLabel?.fallback) {
// A label in another language, never a translation.
console.log(`fallback from ${concept.displayLabel.language}`, concept.displayLabel.fallbackPath);
}
const hits = await author.vocabulary.search({
q: 'Production',
roles: ['preferred', 'alternative'],
});

A refusal throws VocabularyRefusedError with a closed code. Branch on the code: one status carries several codes.

import {
MutationOutcomeUnconfirmedError,
PreconditionFailedError,
VocabularyRefusedError,
} from '@kortexya/reasoninglayer';
// Bind the key once: every attempt below sends the same request identity.
const approveKey = crypto.randomUUID();
try {
await steward.vocabulary.approve(candidateId, cite, { idempotencyKey: approveKey });
} catch (error) {
if (error instanceof MutationOutcomeUnconfirmedError) {
// The outcome is unknown: never assume nothing was written.
// Read the committed outcome; if nothing committed, repeat with the SAME key.
try {
const outcome = await steward.vocabulary.getRequestOutcome('approve', approveKey);
if (outcome.operation === 'approve') console.log(outcome.body.decision.decisionId);
} catch (missing) {
if (missing instanceof VocabularyRefusedError && missing.code === 'no_committed_outcome') {
await steward.vocabulary.approve(candidateId, cite, { idempotencyKey: approveKey });
} else throw missing;
}
} else if (error instanceof VocabularyRefusedError) {
switch (error.code) {
case 'vocabulary_separation_of_duties': // the actor authored this revision
case 'review_missing': // no standing endorsement
case 'stale_baseline': // rebase first
break;
}
} else if (error instanceof PreconditionFailedError) {
// a stale ifMatch on save or rebase
}
}

getRequestOutcome(operation, key) answers the committed status and body, or throws 404 no_committed_outcome (repeating the identical request is then safe). The body is decoded as the operation’s own typed result: narrow on outcome.operation (create_candidate, save_revision and rebase give a candidate revision; assess an assessment; submit, review, approve, reject and withdraw a decision; release a release; activate an activation). It is the body of the first commit, so body.replayed is false. Read it with the same authenticatedUser that sent the mutation.

migrationPreview() shows how the tenant’s domain sorts would become governed concepts: classification, proposed labels and definition, and what would not be converted (a sort’s parent is never proposed as a broader link). It is read-only: writes is always 0, and no candidate is created.