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 acomplete,passedassessment 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-organizationplugin, 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).
Prerequisites
Section titled “Prerequisites”- The first-party knowledge-organization plugin is installed system-wide.
client.vocabulary.status()reports it; every other route exceptgetRequestOutcomerefuses with409 vocabulary_plugin_unavailablewithout it. - The tenant holds the authorization facts: the engine re-proves
author_vocabulary,assess_vocabulary,review_vocabulary,approve_vocabulary,release_vocabularyandactivate_vocabularyat 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}`);Idempotency keys
Section titled “Idempotency keys”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: trueand writes nothing. - The same key with a different request throws
IdempotencyKeyReusedError(422). - A key is 1 to 128 characters of
[A-Za-z0-9._:-].
Author a candidate
Section titled “Author a candidate”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 },);Assess, submit, review, approve
Section titled “Assess, submit, review, approve”// 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.
Release and activate
Section titled “Release and activate”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.
Read the vocabulary
Section titled “Read the vocabulary”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'],});Handle refusals
Section titled “Handle refusals”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.
Migrating a legacy sort-based vocabulary
Section titled “Migrating a legacy sort-based vocabulary”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.