/* * XZBT Exhibit Contract 5.2 — generic contract core. * * WHY THIS FILE IS GENERIC * ------------------------ * Everything in here is domain-free. It knows about the *shape* of the * contract (envelopes, target kinds, revisions, sequences, error codes, * capability lifecycle) and nothing about aquariums, planetariums, haunted * houses, or any other subject matter. It contains no exhibit state, no * exhibit vocabulary, and no transport. * * An exhibit supplies three things and gets a conforming contract surface: * * 1. a target catalog (canonical dotted IDs -> descriptors) * 2. a setter table (target ID -> absolute, idempotent setter) * 3. an action table (target ID -> real impulse implementation) * * The core owns the canonical mutation/invoke chokepoint, so every input * source (native UI, hotkey, host transport) converges on one path and one * set of transaction semantics. That is the whole point of sharing it: the * contract rules are subtle enough that three hand-rolled copies would drift. * * This file is a classic script (no ES modules) and publishes one global. */ (function () { 'use strict'; var CONTRACT_MAJOR = 5; var CONTRACT_MINOR = 2; var XZBT_VERSION = '5.2'; /* Contract 5.2 §15. The exhibit assigns source at its own trusted * boundary; a source supplied by a caller is never trusted. */ var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system']; /* Contract 5.2 §24. */ var ERROR_CODES = [ 'UNSUPPORTED_VERSION', 'INVALID_MESSAGE', 'INVALID_SESSION', 'UNKNOWN_TARGET', 'INVALID_VALUE', 'INVALID_ARGUMENTS', 'CAPABILITY_UNAVAILABLE', 'TARGET_READ_ONLY', 'TARGET_NOT_INVOKABLE', 'TARGET_NOT_SETTABLE', 'INTERNAL_ERROR' ]; /* Contract 5.2 §17. */ var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error']; /* Contract 5.2 §16. The base event set is closed; exhibits do not invent * new canonical event types. */ var EVENT_TYPES = [ 'state.changed', 'action.executed', 'selection.changed', 'capability.changed', 'registry.changed', 'error' ]; var TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); } function isFiniteNumber(v) { return typeof v === 'number' && isFinite(v); } /* ------------------------------------------------------------------ * * Target catalog * ------------------------------------------------------------------ */ /** * A catalog is the exhibit's fixed public surface. It is built once and * never reshaped by ordinary context changes (Authoring Guide §D/N): * a target that is temporarily unusable stays discoverable and reports * CAPABILITY_UNAVAILABLE instead of disappearing. */ function Catalog(descriptors) { this._byId = {}; this._order = []; for (var i = 0; i < descriptors.length; i++) { var d = descriptors[i]; if (!TARGET_ID_PATTERN.test(d.id)) { throw new Error('Invalid canonical target id: ' + d.id); } if (this._byId[d.id]) { throw new Error('Duplicate target id: ' + d.id); } this._byId[d.id] = d; this._order.push(d.id); } } Catalog.prototype.has = function (id) { return Object.prototype.hasOwnProperty.call(this._byId, id); }; Catalog.prototype.get = function (id) { return this.has(id) ? this._byId[id] : null; }; Catalog.prototype.ids = function () { return this._order.slice(); }; /** Descriptors as published by `describe`, in stable catalog order. */ Catalog.prototype.descriptors = function () { var out = []; for (var i = 0; i < this._order.length; i++) { out.push(this._byId[this._order[i]]); } return out; }; /* ------------------------------------------------------------------ * * Capabilities * ------------------------------------------------------------------ */ /** * Capabilities are discoverable and stateful (Contract §17). This registry * only records what the exhibit honestly reports; it does not invent * lifecycle transitions. An exhibit that is always ready says so once. */ function CapabilityRegistry() { this._caps = {}; this._order = []; } CapabilityRegistry.prototype.declare = function (id, state) { if (CAPABILITY_STATES.indexOf(state) === -1) { throw new Error('Unknown capability state: ' + state); } if (!this._caps[id]) this._order.push(id); this._caps[id] = { id: id, state: state }; return this; }; CapabilityRegistry.prototype.stateOf = function (id) { return this._caps[id] ? this._caps[id].state : 'unsupported'; }; CapabilityRegistry.prototype.snapshot = function () { var out = []; for (var i = 0; i < this._order.length; i++) { var c = this._caps[this._order[i]]; out.push({ id: c.id, state: c.state }); } return out; }; /** True when every required capability is usable right now. */ CapabilityRegistry.prototype.allUsable = function (requires) { if (!requires || !requires.length) return true; for (var i = 0; i < requires.length; i++) { var s = this.stateOf(requires[i]); if (s !== 'ready' && s !== 'busy') return false; } return true; }; /* ------------------------------------------------------------------ * * The contract core * ------------------------------------------------------------------ */ /** * @param {object} options * @param {object} options.identity { product, version, build } * @param {Catalog} options.catalog * @param {CapabilityRegistry} options.capabilities * @param {object} options.setters target id -> function(value) -> {changed:boolean} * @param {object} options.readers target id -> function() -> value * @param {object} options.actions target id -> function(args) -> {ok, code?, message?} * @param {object} [options.availability] target id -> function() -> {ok, code?, message?} * @param {function} [options.onEvent] called with every normalized event */ function ContractCore(options) { this.identity = options.identity; this.catalog = options.catalog; this.capabilities = options.capabilities; this.setters = options.setters || {}; this.readers = options.readers || {}; this.actions = options.actions || {}; this.availability = options.availability || {}; this.onEvent = options.onEvent || function () {}; /* stateRevision tracks committed persistent-state history and does NOT * reset when a host reconnects (Contract §14, §16.1). */ this.stateRevision = 0; /* registryRevision tracks the target set and its metadata. The catalog * is fixed, so this starts at 1 and stays there unless the exhibit * genuinely changes its registry. */ this.registryRevision = 1; /* sequence is session-scoped and resets on a new sessionId. */ this.sequence = 0; this.sessionId = null; this._sessionCounter = 0; this._eventLog = []; } ContractCore.prototype._emit = function (type, payload) { if (EVENT_TYPES.indexOf(type) === -1) { throw new Error('Not a canonical contract event type: ' + type); } this.sequence += 1; var event = { xzbt: XZBT_VERSION, type: type, sessionId: this.sessionId, sequence: this.sequence, timestamp: Date.now() }; for (var k in payload) { if (Object.prototype.hasOwnProperty.call(payload, k)) event[k] = payload[k]; } this._eventLog.push(event); this.onEvent(event); return event; }; /** Events emitted so far in the current session (used by tests/harness). */ ContractCore.prototype.eventLog = function () { return this._eventLog.slice(); }; ContractCore.prototype._error = function (code, message) { return { ok: false, error: { code: code, message: message } }; }; /* ------------------------------------------------------------------ * * Value validation * ------------------------------------------------------------------ */ ContractCore.prototype._validateValue = function (descriptor, value) { var kind = descriptor.kind; if (kind === 'range') { if (!isFiniteNumber(value)) { return this._error('INVALID_VALUE', 'Range target requires a finite number.'); } if (value < descriptor.min || value > descriptor.max) { /* No silent clamping: an out-of-range value is rejected outright * (Authoring Guide §T). */ return this._error( 'INVALID_VALUE', 'Value ' + value + ' is outside [' + descriptor.min + ', ' + descriptor.max + '].' ); } if (descriptor.step) { var steps = (value - descriptor.min) / descriptor.step; if (Math.abs(steps - Math.round(steps)) > 1e-9) { return this._error( 'INVALID_VALUE', 'Value ' + value + ' is not on the declared step of ' + descriptor.step + '.' ); } } return { ok: true, value: value }; } if (kind === 'state') { if (descriptor.valueType === 'boolean') { if (typeof value !== 'boolean') { return this._error('INVALID_VALUE', 'State target requires a boolean.'); } return { ok: true, value: value }; } if (descriptor.valueType === 'string') { if (typeof value !== 'string') { return this._error('INVALID_VALUE', 'State target requires a string.'); } if (descriptor.maxLength && value.length > descriptor.maxLength) { return this._error( 'INVALID_VALUE', 'String exceeds declared maxLength of ' + descriptor.maxLength + '.' ); } return { ok: true, value: value }; } if (descriptor.valueType === 'number') { if (!isFiniteNumber(value)) { return this._error('INVALID_VALUE', 'State target requires a finite number.'); } return { ok: true, value: value }; } return this._error('INVALID_VALUE', 'Unsupported valueType.'); } if (kind === 'selection') { if (typeof value !== 'string') { return this._error('INVALID_VALUE', 'Selection target requires a string value.'); } for (var i = 0; i < descriptor.options.length; i++) { if (descriptor.options[i].value === value) return { ok: true, value: value }; } return this._error('INVALID_VALUE', 'Value "' + value + '" is not a declared option.'); } return this._error('INVALID_VALUE', 'Target kind does not accept values.'); }; /* ------------------------------------------------------------------ * * Canonical mutation path (Authoring Guide §H) * ------------------------------------------------------------------ */ /** * The single chokepoint for every externally visible persistent-state * change, whatever its origin. One call is one mutation transaction: * commit, then increment stateRevision at most once, then emit the * resulting events carrying that revision (Contract §14). * * @param {string} targetId * @param {*} value * @param {string} source assigned by the caller's trusted boundary * @param {object} [context] { correlationId } */ ContractCore.prototype.applyMutation = function (targetId, value, source, context) { context = context || {}; if (SOURCES.indexOf(source) === -1) { return this._error('INTERNAL_ERROR', 'Unknown source: ' + source); } var descriptor = this.catalog.get(targetId); if (!descriptor) { return this._error('UNKNOWN_TARGET', 'The requested target is not registered.'); } if (descriptor.kind === 'impulse') { return this._error('TARGET_NOT_SETTABLE', 'Impulse targets are not settable.'); } if (!descriptor.writable) { return this._error('TARGET_READ_ONLY', 'The requested target is read-only.'); } if (!this.capabilities.allUsable(descriptor.requires)) { return this._error( 'CAPABILITY_UNAVAILABLE', 'A capability required by this target is not usable.' ); } var validated = this._validateValue(descriptor, value); if (!validated.ok) return validated; var setter = this.setters[targetId]; if (typeof setter !== 'function') { return this._error('INTERNAL_ERROR', 'No setter is bound to this target.'); } /* The setter is absolute and idempotent: it reports whether anything * actually changed. A no-op must not touch stateRevision (Contract §14.3). */ var result = setter(validated.value) || {}; if (!result.changed) { return { ok: true, revisionChanged: false, stateRevision: this.stateRevision }; } this.stateRevision += 1; var revision = this.stateRevision; /* A coherent multi-value transaction reports every value it changed so * all of them are emitted under the one shared revision. */ var changed = result.changedTargets && result.changedTargets.length ? result.changedTargets : [targetId]; for (var i = 0; i < changed.length; i++) { var id = changed[i]; var d = this.catalog.get(id); if (!d || !d.readable) continue; var payload = { stateRevision: revision, target: id, value: this.readValue(id), source: source, /* Always present, null when the change did not originate from a * request. Emitting the key unconditionally keeps the event shape * identical no matter which input path caused the change, so a host * can rely on one schema (Authoring Guide §H). */ correlationId: context.correlationId || null }; this._emit(d.kind === 'selection' ? 'selection.changed' : 'state.changed', payload); } return { ok: true, revisionChanged: true, stateRevision: revision }; }; /** * The single chokepoint for every externally visible action. * Impulses never change persistent state, so they never touch * stateRevision (Contract §10.4, §14). */ ContractCore.prototype.invokeAction = function (targetId, args, source, context) { context = context || {}; if (SOURCES.indexOf(source) === -1) { return this._error('INTERNAL_ERROR', 'Unknown source: ' + source); } var descriptor = this.catalog.get(targetId); if (!descriptor) { return this._error('UNKNOWN_TARGET', 'The requested target is not registered.'); } if (descriptor.kind !== 'impulse') { return this._error('TARGET_NOT_INVOKABLE', 'Only impulse targets are invokable.'); } if (!this.capabilities.allUsable(descriptor.requires)) { return this._error( 'CAPABILITY_UNAVAILABLE', 'A capability required by this target is not usable.' ); } /* Contextual availability: the target stays discoverable, but may be * temporarily unusable because of exhibit state (Authoring Guide §N). */ var gate = this.availability[targetId]; if (typeof gate === 'function') { var verdict = gate(); if (verdict && !verdict.ok) { return this._error(verdict.code || 'CAPABILITY_UNAVAILABLE', verdict.message || 'Unavailable.'); } } var action = this.actions[targetId]; if (typeof action !== 'function') { return this._error('INTERNAL_ERROR', 'No action is bound to this target.'); } // Fixture-only correction for the owner's authoritative 5.2 clarification. if (!isPlainObject(args)) return this._error('INVALID_VALUE', 'args must be an object.'); var declared = descriptor.arguments || []; var names = declared.map(function (arg) { return arg.name; }); if (Object.keys(args).some(function (key) { return names.indexOf(key) === -1; })) { return this._error('INVALID_VALUE', 'Undeclared argument.'); } for (var a = 0; a < declared.length; a++) { var spec = declared[a]; if (!Object.prototype.hasOwnProperty.call(args, spec.name)) { if (spec.required) return this._error('INVALID_VALUE', 'Missing argument: ' + spec.name); continue; } var value = args[spec.name]; var validType = spec.type === 'integer' ? Number.isSafeInteger(value) : spec.type === 'number' ? isFiniteNumber(value) : typeof value === spec.type; if (!validType || (spec.enum && spec.enum.indexOf(value) === -1)) { return this._error('INVALID_VALUE', 'Invalid argument: ' + spec.name); } if (typeof value === 'number') { var units = (value - (spec.min === undefined ? 0 : spec.min)) / spec.step; if ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max) || (spec.step !== undefined && Math.abs(units - Math.round(units)) > 1e-7)) { return this._error('INVALID_VALUE', 'Argument outside numeric constraints.'); } } if (typeof value === 'string' && ((spec.minLength !== undefined && value.length < spec.minLength) || (spec.maxLength !== undefined && value.length > spec.maxLength))) { return this._error('INVALID_VALUE', 'Argument outside length constraints.'); } } var outcome = action(args); if (!outcome || !outcome.ok) { return this._error( (outcome && outcome.code) || 'INTERNAL_ERROR', (outcome && outcome.message) || 'The action could not be executed.' ); } var payload = { target: targetId, args: outcome.args || args || {}, source: source, correlationId: context.correlationId || null }; this._emit('action.executed', payload); return { ok: true }; }; /* ------------------------------------------------------------------ * * Reads * ------------------------------------------------------------------ */ ContractCore.prototype.readValue = function (targetId) { var reader = this.readers[targetId]; if (typeof reader === 'function') return reader(); return null; }; /** Contract §13: only readable persistent targets belong in `values`. */ ContractCore.prototype.stateSnapshot = function () { var values = {}; var ids = this.catalog.ids(); for (var i = 0; i < ids.length; i++) { var d = this.catalog.get(ids[i]); if (d.kind === 'impulse' || !d.readable) continue; values[ids[i]] = this.readValue(ids[i]); } return { stateRevision: this.stateRevision, values: values }; }; ContractCore.prototype.describe = function () { return { exhibit: this.identity, contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }, registryRevision: this.registryRevision, stateRevision: this.stateRevision, capabilities: this.capabilities.snapshot(), targets: this.catalog.descriptors() }; }; /* ------------------------------------------------------------------ * * Capability transitions * ------------------------------------------------------------------ */ ContractCore.prototype.setCapabilityState = function (id, state) { var previous = this.capabilities.stateOf(id); if (previous === state) return false; this.capabilities.declare(id, state); this._emit('capability.changed', { capability: id, state: state, previousState: previous }); return true; }; /* ------------------------------------------------------------------ * * Session handling * ------------------------------------------------------------------ */ ContractCore.prototype._newSessionId = function () { this._sessionCounter += 1; var rand = Math.floor(Math.random() * 0xffff).toString(16); return 'sess-' + this._sessionCounter.toString(16) + rand; }; /** * Handshake (Contract §5). A new session resets the event sequence but * deliberately leaves stateRevision alone. */ ContractCore.prototype.handleHello = function (message) { var majors = message.supportedContractMajors; if (Array.isArray(majors) && majors.length && majors.indexOf(CONTRACT_MAJOR) === -1) { return { ok: false, error: { code: 'UNSUPPORTED_VERSION', message: 'No compatible contract major. This exhibit implements major ' + CONTRACT_MAJOR + '.' } }; } this.sessionId = this._newSessionId(); this.sequence = 0; this._eventLog = []; return { ok: true, result: { sessionId: this.sessionId, exhibit: this.identity, contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR } } }; }; /* ------------------------------------------------------------------ * * Request dispatch * ------------------------------------------------------------------ */ /** * Handle one already-parsed contract request. Transport-agnostic: the * caller decides how the message arrived and what `source` that implies. * * @param {object} message * @param {string} source the authoritative source for this arrival path * @returns {object|null} response envelope, or null when the message is * not addressed to this exhibit at all */ ContractCore.prototype.handleRequest = function (message, source) { if (!isPlainObject(message)) { return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.'); } if (message.xzbt !== XZBT_VERSION) { return this._errorEnvelope( message.requestId || null, message.sessionId || null, 'UNSUPPORTED_VERSION', 'This exhibit implements contract ' + XZBT_VERSION + '.' ); } if (typeof message.type !== 'string') { return this._errorEnvelope( message.requestId || null, message.sessionId || null, 'INVALID_MESSAGE', 'Message type is required.' ); } var requestId = typeof message.requestId === 'string' ? message.requestId : null; if (message.type === 'hello') { var hello = this.handleHello(message); if (!hello.ok) { return this._errorEnvelope(requestId, null, hello.error.code, hello.error.message); } return this._okEnvelope('hello.result', requestId, hello.result); } /* Every other request must carry the session issued by this exhibit. */ if (typeof message.sessionId !== 'string' || message.sessionId !== this.sessionId) { return this._errorEnvelope( requestId, typeof message.sessionId === 'string' ? message.sessionId : null, 'INVALID_SESSION', 'A valid sessionId issued by this exhibit is required.' ); } switch (message.type) { case 'describe': return this._okEnvelope('describe.result', requestId, this.describe()); case 'state.get': return this._okEnvelope('state.result', requestId, this.stateSnapshot()); case 'set': { if (typeof message.target !== 'string') { return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'set requires a target.'); } var setResult = this.applyMutation(message.target, message.value, source, { correlationId: requestId }); if (!setResult.ok) { return this._errorEnvelope( requestId, this.sessionId, setResult.error.code, setResult.error.message ); } return this._okEnvelope('set.result', requestId, { stateRevision: setResult.stateRevision, changed: !!setResult.revisionChanged }); } case 'invoke': { if (typeof message.target !== 'string') { return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'invoke requires a target.'); } var invokeResult = this.invokeAction(message.target, message.args, source, { correlationId: requestId }); if (!invokeResult.ok) { return this._errorEnvelope( requestId, this.sessionId, invokeResult.error.code, invokeResult.error.message ); } return this._okEnvelope('invoke.result', requestId, {}); } default: return this._errorEnvelope( requestId, this.sessionId, 'INVALID_MESSAGE', 'Unsupported message type: ' + message.type ); } }; ContractCore.prototype._okEnvelope = function (type, requestId, payload) { var envelope = { xzbt: XZBT_VERSION, type: type, requestId: requestId, sessionId: this.sessionId, ok: true }; for (var k in payload) { if (Object.prototype.hasOwnProperty.call(payload, k)) envelope[k] = payload[k]; } return envelope; }; ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) { return { xzbt: XZBT_VERSION, type: 'error', requestId: requestId, sessionId: sessionId, ok: false, error: { code: code, message: message } }; }; /* ------------------------------------------------------------------ * * Exports * ------------------------------------------------------------------ */ window.XZBTContractCore = { VERSION: XZBT_VERSION, CONTRACT_MAJOR: CONTRACT_MAJOR, CONTRACT_MINOR: CONTRACT_MINOR, SOURCES: SOURCES, ERROR_CODES: ERROR_CODES, CAPABILITY_STATES: CAPABILITY_STATES, EVENT_TYPES: EVENT_TYPES, TARGET_ID_PATTERN: TARGET_ID_PATTERN, Catalog: Catalog, CapabilityRegistry: CapabilityRegistry, ContractCore: ContractCore }; })();