Files
XZBT-NGN/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js
T
Labyricorn 745912e451 Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface
One commit for the work accumulated in the working tree since Step 6.3,
which had never been split into per-step commits:

- src/local-surfaces.js + src/surface-url.js (new); src/ui.js,
  src/validation.js, src/connection.js and public/index.html updated for
  local-surface hosting and generic surface rendering
- tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7)
  new; connection/museum-gallery/surface-validation suites updated
- reference exhibits: shared/contract-core.js defaults to Contract 5.3
  (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface
  catalog; aquarium/haunted-house/planetarium adapters updated
- SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js,
  Observation-surface boot branch, local-change hooks, view.pillars /
  view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT
- SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no
  longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on
  its presence/type, matching the host's own envelope validation; the
  adapter now advertises contract minor 3 / version 5.3.0, which it already
  implemented via the 5.3 surfaces field. Root cause of the five failing
  postmessage-interop tests (host hello was silently dropped).
- docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs;
  test-fixtures/PROVENANCE.md resync record

Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7);
SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check
clean for changed files; two pre-existing trailing-whitespace lines remain
in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from
the authoritative SciFi source.

Step 6.7 live verification (browser Observation, packaged standalone) is
still pending and is not claimed here.
2026-09-14 19:45:27 -07:00

1061 lines
34 KiB
JavaScript

/**
* XZBT Contract Adapter — Specification v5.3
*
* Implements the normalized contract interface for SciFi-XZBT:
* - Canonical external target catalog & descriptors
* - Safe mutation & impulse chokepoints
* - State revision & monotonic event sequence
* - Authoritative source attribution
* - Host communication bridge (postMessage)
*
* Zero external dependencies. Browser & Node compatible.
*/
(function () {
class XZBTContractAdapter {
constructor(options = {}) {
this.product = options.product || 'SciFi-XZBT';
this.version = options.version || '5.3.0';
this.build = options.build || 'production';
this.contractMajor = 5;
this.contractMinor = 3;
this.sessionId = null;
this.sessionActive = false;
this.eventSequence = 0;
this.stateRevision = 0;
this.registryRevision = 1;
this.operationQueue = Promise.resolve();
this.eventSink = null;
// Surface catalog (Contract 5.3 §31.2/§31.3). Advertised in describe()
// only when non-empty, so absence stays §31.3 form 1 ("absent").
this.surfaces = Array.isArray(options.surfaces) ? options.surfaces : null;
this.instanceId = options.instanceId || null;
// Session-independent local-change hooks (Step 6.7B). Unlike
// _emitEvent, these fire regardless of sessionActive so an
// exhibit-internal surface mirror keeps working with no NGN attached.
this.onLocalChange = typeof options.onLocalChange === 'function' ? options.onLocalChange : null;
this.onLocalAction = typeof options.onLocalAction === 'function' ? options.onLocalAction : null;
// Transport hooks
this.onMessage = options.onMessage || (() => {});
this.hostOrigin = options.hostOrigin || null; // optional origin filter
this.trustedSources = new Set();
// Subsystem binders & state accessors supplied by app.js
this.bindings = options.bindings || {};
// Capabilities: audio, speech, observation, display-text
// NOTE: midi capability removed in Step 3.5 — MIDI integration belongs to XZBT-NGN.
this.capabilities = new Map([
['audio', { id: 'audio', state: 'ready' }],
['speech', { id: 'speech', state: 'available' }],
['observation', { id: 'observation', state: 'ready' }],
['display-text', { id: 'display-text', state: 'ready' }]
]);
// Canonical target registry
this.targets = new Map();
this.legacyIdMap = new Map(); // internal ID -> canonical ID
this._initCanonicalTargets();
// Setup window listener if running in browser
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
this._setupWindowBridge();
}
}
_initCanonicalTargets() {
const reg = (target) => {
this.targets.set(target.id, target);
if (target.legacyId) {
this.legacyIdMap.set(target.legacyId, target.id);
}
};
// --- 1. Continuous Range Mixers & Controls (14 targets) ---
reg({
id: 'mix.master',
legacyId: 'master-vol',
label: 'Master Volume',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.hull.level',
legacyId: 'hull-vol',
label: 'Hull Drone Level',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.hull.frequency',
legacyId: 'hull-freq',
label: 'Hull Drone Frequency',
kind: 'range',
min: 30,
max: 120,
step: 1,
unit: 'hz',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.hull.cutoff',
legacyId: 'hull-cutoff',
label: 'Hull Drone Cutoff',
kind: 'range',
min: 40,
max: 400,
step: 1,
unit: 'hz',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.drive.level',
legacyId: 'warp-vol',
label: 'Drive / Core Level',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.drive.pulse-rate',
legacyId: 'warp-bpm',
label: 'Drive Pulse Rate',
kind: 'range',
min: 20,
max: 120,
step: 1,
unit: 'bpm',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.drive.carrier',
legacyId: 'warp-carrier',
label: 'Drive Carrier Frequency',
kind: 'range',
min: 30,
max: 140,
step: 0.5,
unit: 'hz',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.environment.level',
legacyId: 'air-vol',
label: 'Environmental Airflow Level',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.environment.cutoff',
legacyId: 'air-cutoff',
label: 'Environmental Filter Cutoff',
kind: 'range',
min: 500,
max: 4000,
step: 50,
unit: 'hz',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.telemetry.level',
legacyId: 'telemetry-vol',
label: 'Telemetry Level',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'mix.telemetry.density',
legacyId: 'telemetry-density',
label: 'Telemetry Density',
kind: 'range',
min: 0,
max: 1,
step: 0.05,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'speech.robot-amount',
legacyId: 'ai-robot-amount',
label: 'Speech Robotic Modulation',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'speech',
requires: ['speech']
});
reg({
id: 'fnc.level',
legacyId: 'fnc-level',
label: 'Futuristic Noise Cancellation Level',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'view.activity',
legacyId: 'observation-activity',
label: 'Observation Visual Activity',
kind: 'range',
min: 0,
max: 1,
step: 0.01,
unit: 'ratio',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
// --- 2. Persistent State Targets (Booleans & Selections) ---
reg({
id: 'transport.playing',
legacyId: 'master-play',
label: 'Audio Engine Online',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'transport',
requires: ['audio']
});
reg({
id: 'transport.muted',
legacyId: 'mute',
label: 'Master Mute',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'transport',
requires: ['audio']
});
reg({
id: 'fnc.enabled',
legacyId: 'fnc-enable',
label: 'Futuristic Noise Cancellation Active',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'audio',
requires: ['audio']
});
reg({
id: 'view.observation',
legacyId: 'watch-experience',
label: 'Observation Viewport Active',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: false,
category: 'observation',
requires: ['observation']
});
reg({
id: 'view.viewport-frame',
legacyId: 'observation-viewport',
label: 'Starship Bezel Frame Framing',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'view.pillars',
legacyId: 'observation-pillars',
label: 'Window Pillars / Mullions',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'view.warp-flight',
label: 'Warp Flight Mode',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'alert.active',
label: 'Active Tactical Alert Status',
kind: 'selection',
options: [
{ value: 'none', label: 'Condition Green (Normal)' },
{ value: 'yellow', label: 'Yellow Alert' },
{ value: 'red', label: 'Red Alert' }
],
readable: true,
writable: true,
restorable: true,
category: 'alert',
requires: ['audio']
});
reg({
id: 'universe.selected',
label: 'Active Sci-Fi Universe',
kind: 'selection',
options: [
{ value: 'starfleet', label: 'Starfleet / Galaxy' },
{ value: 'whataverse', label: 'Whataverse / Tardix' },
{ value: 'industrial', label: 'Industrial / Weyland' },
{ value: 'bioships', label: 'Bioships / Leviathan' },
{ value: 'retrofuture', label: 'Retro Future / 1960s' },
{ value: 'military', label: 'Colonial Military' },
{ value: 'deepspace', label: 'Deep Space Generation' },
{ value: 'outlaw', label: 'Frontier & Salvage' },
{ value: 'spacestations', label: 'Orbital Stations & Docks' },
{ value: 'comedy', label: 'Whimsical Fleet' }
],
readable: true,
writable: true,
restorable: true,
category: 'universe',
requires: ['audio']
});
reg({
id: 'preset.selected',
label: 'Active Audio Preset',
kind: 'selection',
options: this._collectAllPresetOptions(),
readable: true,
writable: true,
restorable: true,
category: 'preset',
requires: ['audio']
});
// --- 3. Impulse Targets (Speech & Ticker) ---
reg({
id: 'speech.say',
label: 'Speak Text Line',
kind: 'impulse',
readable: false,
writable: false,
restorable: false,
category: 'speech',
requires: ['speech'],
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 2000 }
]
});
reg({
id: 'speech.generate-announcement',
legacyId: 'generate-announcement',
label: 'Generate Ambient AI Announcement',
kind: 'impulse',
readable: false,
writable: false,
restorable: false,
category: 'speech',
requires: ['speech']
});
reg({
id: 'display.ticker',
label: 'Display Ticker Message (Transient)',
kind: 'impulse',
readable: false,
writable: false,
restorable: false,
category: 'display',
requires: ['display-text'],
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 512 }
]
});
// --- 4. Canonical Universe Event Impulses ---
const eventImpulses = [
{ id: 'event.alert-red', legacyId: 'red-alert', label: 'Red Alert Impulse' },
{ id: 'event.warp-jump', label: 'Warp Jump Impulse' },
{ id: 'event.demat', label: 'Demat Cycle Impulse' },
{ id: 'event.vortex', label: 'Time Vortex Impulse' },
{ id: 'event.cloister', label: 'Cloister Bell Impulse' },
{ id: 'event.flak-barrage', label: 'Flak Barrage Impulse' },
{ id: 'event.ftl-jump', label: 'FTL Jump Impulse' },
{ id: 'event.singularity', label: 'Singularity Engage Impulse' },
{ id: 'event.solar-roar', label: 'Solar Flare Roar Impulse' },
{ id: 'event.afterburner', label: 'Afterburner Ignite Impulse' },
{ id: 'event.ludicrous-speed', label: 'Ludicrous Speed Impulse' },
{ id: 'event.improbability-flip', label: 'Improbability Drive Flip' }
];
eventImpulses.forEach(evt => {
reg({
id: evt.id,
legacyId: evt.legacyId,
label: evt.label,
kind: 'impulse',
readable: false,
writable: false,
restorable: false,
category: 'event',
requires: ['audio']
});
});
// --- 5. Canonical SFX Impulses ---
const sfxImpulses = [
{ id: 'sfx.comm-badge', label: 'Comm Badge Chime' },
{ id: 'sfx.door-swish', label: 'Pneumatic Door Swish' },
{ id: 'sfx.bosun-whistle', label: 'Bosun Pipe Call' },
{ id: 'sfx.medical-monitor', label: 'Medical Vital Monitor' },
{ id: 'sfx.cardassian-door', label: 'Heavy Bulkhead Door' },
{ id: 'sfx.turbolift', label: 'Turbochute Whoosh' },
{ id: 'sfx.replicator', label: 'Fabricator Materialize' },
{ id: 'sfx.transporter', label: 'Translocator Beam' },
{ id: 'sfx.console-chirp', label: 'Console Single Chirp' },
{ id: 'sfx.sonic', label: 'Sonic Acoustic Screwdriver' },
{ id: 'sfx.lever-clunk', label: 'Console Mechanical Lever Clunk' },
{ id: 'sfx.tardix-door', label: 'Tardix Wood & Lock Door' },
{ id: 'sfx.demat-switch', label: 'Demat Switch Rotor' },
{ id: 'sfx.telepathic-chime', label: 'Telepathic Circuit Chime' },
{ id: 'sfx.air-handler', label: 'Air Handler Valve Thud' },
{ id: 'sfx.mag-boot', label: 'Mag-Boot Solenoid Latch' },
{ id: 'sfx.comlock', label: 'Comlock Electronic Tone' },
{ id: 'sfx.solar-roar', label: 'Solar Radiation Wind' },
{ id: 'sfx.tape-clunk', label: 'Cassette Magnetic Transport Clunk' },
{ id: 'sfx.afterburner', label: 'Thruster Burner Pulse' },
{ id: 'sfx.geiger', label: 'Radiation Geiger Counter' },
{ id: 'sfx.dock-clamp', label: 'Air Valve & Dock Clamp' },
{ id: 'sfx.dradis-ping', label: 'Dradis Combat Sonar Ping' },
{ id: 'sfx.hal-chime', label: 'HAL Logic Melodic Chime' }
];
sfxImpulses.forEach(sfx => {
reg({
id: sfx.id,
label: sfx.label,
kind: 'impulse',
readable: false,
writable: false,
restorable: false,
category: 'sfx',
requires: ['audio']
});
});
}
_collectAllPresetOptions() {
const options = [];
if (typeof window !== 'undefined' && window.UniverseRegistry) {
for (const uId of Object.keys(window.UniverseRegistry)) {
const u = window.UniverseRegistry[uId];
if (u.presets) {
for (const pId of Object.keys(u.presets)) {
options.push({ value: pId, label: u.presets[pId].name || pId, universe: uId });
}
}
}
}
if (!options.length) {
options.push(
{ value: 'tng-bridge', label: 'Interprise-G: Main Bridge', universe: 'starfleet' },
{ value: 'tardix-1963', label: 'Type 40 TT: 1963 First Console', universe: 'whataverse' }
);
}
return options;
}
_generateSessionId() {
return 'sess-' + Math.random().toString(36).substring(2, 10);
}
_setupWindowBridge() {
window.addEventListener('message', (e) => {
// Enforce safe origin filtering if configured
if (e.origin !== window.location.origin && e.origin !== this.hostOrigin) {
return;
}
// Enforce safe source: must be parent, opener, or self
if (e.source !== window.parent && e.source !== window.opener && e.source !== window) {
return;
}
// Ignore non-XZBT messages
if (!e.data || typeof e.data !== 'object' || typeof e.data.xzbt !== 'string') {
return;
}
if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return;
const replySink = (msg) => {
try {
if (e.source && typeof e.source.postMessage === 'function') {
const targetOrigin = e.origin === 'null' ? '*' : e.origin;
e.source.postMessage(msg, targetOrigin);
}
} catch (err) {
console.warn('[XZBTContractAdapter] Failed to postMessage reply:', err);
}
};
this.handleMessage(e.data, 'host', replySink);
});
}
// Normalized event emission
_emitEvent(type, payload, correlationId = null, authoritativeSource = 'system') {
if (!this.sessionActive) return;
this.eventSequence++;
const evt = {
xzbt: '5.2',
type,
sessionId: this.sessionId,
sequence: this.eventSequence,
timestamp: Date.now(),
...payload
};
if (correlationId) {
evt.correlationId = correlationId;
}
if (authoritativeSource) {
evt.source = authoritativeSource;
}
this.onMessage(evt);
if (this.eventSink) this.eventSink(evt);
}
_enqueue(operation) {
const result = this.operationQueue.then(operation);
this.operationQueue = result.catch(() => {});
return result;
}
_commitChanges(before, source, correlationId) {
const after = this.getContractState();
const changes = [...this.targets.values()].filter(t => t.readable && t.kind !== 'impulse' && before[t.id] !== after[t.id]);
if (!changes.length) return false;
this.stateRevision++;
for (const target of changes) {
this._emitEvent(target.kind === 'selection' ? 'selection.changed' : 'state.changed', {
stateRevision: this.stateRevision, target: target.id, value: after[target.id]
}, correlationId, source);
}
// Session-independent mirror feed (Step 6.7B). _emitEvent above is
// gated on sessionActive and therefore silent with no NGN attached;
// onLocalChange is not, so an exhibit-internal surface mirror keeps
// working standalone. Fires for every changed target, same as above.
if (this.onLocalChange) {
for (const target of changes) {
this.onLocalChange(target.id, after[target.id], this.stateRevision);
}
}
return true;
}
// Public API: Direct Mutation Chokepoint
applyMutation(targetId, rawValue, source = 'system', correlationId = null) {
return this._enqueue(() => this._applyMutation(targetId, rawValue, source, correlationId));
}
async _applyMutation(targetId, rawValue, source, correlationId) {
// Map legacy ID if necessary
const canonicalId = this.legacyIdMap.get(targetId) || targetId;
const target = this.targets.get(canonicalId);
if (!target) {
return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
}
if (!target.writable || target.kind === 'impulse') {
return { ok: false, code: 'TARGET_NOT_SETTABLE', message: `Target '${canonicalId}' is not writable` };
}
// Check capability availability
if (target.requires) {
for (const capId of target.requires) {
const cap = this.capabilities.get(capId);
if (cap && cap.state === 'unsupported') {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
}
}
}
// Value validation & clamping
let value = rawValue;
if (target.kind === 'range') {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return { ok: false, code: 'INVALID_VALUE', message: `Value must be a valid number` };
}
if (value < target.min || value > target.max) {
return {
ok: false,
code: 'INVALID_VALUE',
message: `Value ${value} is outside range [${target.min}, ${target.max}]`
};
}
const steps = (value - target.min) / target.step;
if (target.step && Math.abs(steps - Math.round(steps)) > 1e-7) {
return { ok: false, code: 'INVALID_VALUE', message: `Value must align to step ${target.step} from ${target.min}` };
}
} else if (target.kind === 'state') {
if (target.valueType === 'boolean' && typeof value !== 'boolean') {
return { ok: false, code: 'INVALID_VALUE', message: `Value must be a boolean` };
}
} else if (target.kind === 'selection') {
const validOptions = target.options.map(o => o.value);
if (!validOptions.includes(value)) {
return {
ok: false,
code: 'INVALID_VALUE',
message: `Value '${value}' is not a valid option for '${canonicalId}'. Valid: [${validOptions.join(', ')}]`
};
}
}
// Read current state to check for idempotent no-op
const currentState = this.getContractState();
const currentValue = currentState[canonicalId];
if (currentValue === value) {
// Idempotent no-op: succeeds immediately, NO revision increment, NO event
return { ok: true, noop: true, stateRevision: this.stateRevision };
}
// Special atomic handling for universe.selected:
// Must also change preset.selected in the same transaction
if (canonicalId === 'universe.selected') {
if (!this.bindings.setUniverse) {
return { ok: false, code: 'INTERNAL_ERROR', message: 'No universe setter bound' };
}
await this.bindings.setUniverse(value, source);
this._commitChanges(currentState, source, correlationId);
return { ok: true, stateRevision: this.stateRevision };
}
// Special handling for preset.selected: validate preset belongs to active universe
if (canonicalId === 'preset.selected') {
if (this.bindings.getUniverse && window.UniverseRegistry) {
const uId = this.bindings.getUniverse();
const u = window.UniverseRegistry[uId];
if (!u || !u.presets || !u.presets[value]) {
return {
ok: false,
code: 'INVALID_VALUE',
message: `Preset '${value}' does not belong to active universe '${uId}'`
};
}
}
if (this.bindings.selectPreset) {
await this.bindings.selectPreset(value, source);
}
this._commitChanges(currentState, source, correlationId);
return { ok: true, stateRevision: this.stateRevision };
}
// Apply direct underlying setter for other targets
const applyFn = this.bindings.setters ? this.bindings.setters[canonicalId] : null;
if (applyFn) {
try {
await applyFn(value, source);
} catch (err) {
return { ok: false, code: 'SETTER_ERROR', message: err.message || String(err) };
}
}
this._commitChanges(currentState, source, correlationId);
return { ok: true, stateRevision: this.stateRevision };
}
// Public API: Direct Impulse Invocation Chokepoint
invokeAction(targetId, args = {}, source = 'system', correlationId = null) {
return this._enqueue(() => this._invokeAction(targetId, args, source, correlationId));
}
async _invokeAction(targetId, args, source, correlationId) {
const canonicalId = this.legacyIdMap.get(targetId) || targetId;
const target = this.targets.get(canonicalId);
if (!target) {
return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
}
if (target.kind !== 'impulse') {
return { ok: false, code: 'TARGET_NOT_INVOKABLE', message: `Target '${canonicalId}' is not an impulse` };
}
// Validate args schema & text length limits if defined
const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
const argumentSpecs = target.arguments || [];
if (Object.keys(sanitizedArgs).some(key => !argumentSpecs.some(spec => spec.name === key))) {
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
}
if (argumentSpecs.length) {
for (const argSpec of argumentSpecs) {
const argKey = argSpec.name;
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
}
if (sanitizedArgs[argKey] !== undefined) {
if (argSpec.type === 'string' && typeof sanitizedArgs[argKey] !== 'string') {
return { ok: false, code: 'INVALID_VALUE', message: `Argument '${argKey}' must be a string` };
}
if (argSpec.maxLength && typeof sanitizedArgs[argKey] === 'string') {
if (sanitizedArgs[argKey].length > argSpec.maxLength) {
return {
ok: false,
code: 'INVALID_VALUE',
message: `Argument '${argKey}' exceeds maximum length of ${argSpec.maxLength}`
};
}
}
}
}
}
// Contextual check: if target is a universe event or SFX, ensure capability is ready
if (target.requires) {
for (const capId of target.requires) {
const cap = this.capabilities.get(capId);
if (cap && cap.state === 'unsupported') {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
}
}
}
// Execute bound impulse handler
const before = this.getContractState();
const invoker = this.bindings.invokers ? this.bindings.invokers[canonicalId] : null;
if (invoker) {
try {
await invoker(sanitizedArgs, source);
} catch (err) {
return { ok: false, code: 'INVOCATION_FAILED', message: err.message || String(err) };
}
} else {
// Fallback for universe events or soundboard triggers
if (canonicalId.startsWith('event.') && this.bindings.handleUniverseEvent) {
const evtType = canonicalId.replace(/^event\./, '');
const handled = await this.bindings.handleUniverseEvent(evtType, source);
if (!handled) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Event '${evtType}' not available in active universe` };
}
} else if (canonicalId.startsWith('sfx.') && this.bindings.handleSoundboardTrigger) {
const sfxId = 'btn-' + canonicalId.replace(/^sfx\./, '');
const handled = await this.bindings.handleSoundboardTrigger(sfxId, source);
if (!handled) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `SFX '${canonicalId}' not available in active universe` };
}
}
}
this._commitChanges(before, source, correlationId);
// Impulses only increment stateRevision if they change persistent state.
this._emitEvent('action.executed', {
target: canonicalId,
args: sanitizedArgs
}, correlationId, source);
// Session-independent mirror feed (Step 6.7B); see _commitChanges.
if (this.onLocalAction) {
this.onLocalAction(canonicalId, sanitizedArgs);
}
return { ok: true };
}
// Public API: Return contract-visible snapshot
getContractState() {
if (this.bindings.getState) {
return this.bindings.getState();
}
return {};
}
// Public API: Protocol Description
describe() {
const description = {
exhibit: {
product: this.product,
version: this.version,
build: this.build
},
contract: {
major: this.contractMajor,
minor: this.contractMinor
},
registryRevision: this.registryRevision,
stateRevision: this.stateRevision,
capabilities: Array.from(this.capabilities.values()),
targets: Array.from(this.targets.values())
};
if (this.surfaces && this.surfaces.length) {
description.surfaces = this.surfaces;
}
return description;
}
// Update Capability state and emit capability.changed
updateCapability(capabilityId, newState) {
const cap = this.capabilities.get(capabilityId);
if (!cap) return;
if (cap.state === newState) return;
cap.state = newState;
this._emitEvent('capability.changed', {
capability: capabilityId,
state: newState
}, null, 'system');
}
// Add target dynamically and emit registry.changed
addCustomTarget(spec) {
this.targets.set(spec.id, spec);
if (spec.legacyId) this.legacyIdMap.set(spec.legacyId, spec.id);
this.registryRevision++;
this._emitEvent('registry.changed', {
registryRevision: this.registryRevision
}, null, 'system');
}
// Handle inbound message (envelope) from host
async handleMessage(msg, transportSource = 'host', replySink = null) {
const sink = replySink || this.onMessage;
const sendError = (reqId, sessId, code, message) => {
const err = {
xzbt: '5.2',
type: 'error',
requestId: reqId || null,
sessionId: sessId || null,
ok: false,
error: { code, message }
};
sink(err);
return err;
};
const sendResult = (type, reqId, payload = {}) => {
const res = {
xzbt: '5.2',
type,
requestId,
sessionId: this.sessionId,
ok: true,
...payload
};
sink(res);
return res;
};
if (!msg || typeof msg !== 'object') {
return sendError(null, null, 'INVALID_MESSAGE', 'Message envelope must be an object');
}
const { type, requestId, sessionId } = msg;
// 1. Handshake
if (type === 'hello') {
const { supportedContractMajors } = msg;
const isSupported = Array.isArray(supportedContractMajors) &&
supportedContractMajors.includes(this.contractMajor);
if (!isSupported) {
return sendError(
requestId,
null,
'UNSUPPORTED_VERSION',
`Exhibit requires contract major ${this.contractMajor}`
);
}
// Establish session and reset sequence counter
this.sessionId = this._generateSessionId();
this.sessionActive = true;
this.eventSequence = 0;
this.eventSink = replySink;
return sendResult('hello.result', requestId, {
exhibit: {
product: this.product,
version: this.version,
build: this.build
},
contract: {
major: this.contractMajor,
minor: this.contractMinor
}
});
}
// 2. All other operations require an active session
if (!this.sessionActive || !sessionId || sessionId !== this.sessionId) {
return sendError(requestId, sessionId, 'INVALID_SESSION', 'Missing or invalid sessionId');
}
// Authoritative source: ignore host-supplied source
const authoritativeSource = transportSource || 'host';
switch (type) {
case 'describe':
return sendResult('describe.result', requestId, this.describe());
case 'state.get': {
const fullState = this.getContractState();
const values = {};
for (const [id, target] of this.targets.entries()) {
if (target.readable && target.kind !== 'impulse') {
values[id] = fullState[id];
}
}
return sendResult('state.result', requestId, {
stateRevision: this.stateRevision,
values
});
}
case 'set': {
const { target: targetId, value } = msg;
const res = await this.applyMutation(targetId, value, authoritativeSource, requestId);
if (!res.ok) {
return sendError(requestId, this.sessionId, res.code, res.message);
}
return sendResult('set.result', requestId);
}
case 'invoke': {
const { target: targetId, args } = msg;
const res = await this.invokeAction(targetId, args, authoritativeSource, requestId);
if (!res.ok) {
return sendError(requestId, this.sessionId, res.code, res.message);
}
return sendResult('invoke.result', requestId);
}
default:
return sendError(requestId, sessionId, 'INVALID_MESSAGE', `Unknown message type: ${type}`);
}
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { XZBTContractAdapter };
}
if (typeof window !== 'undefined') {
window.XZBTContractAdapter = XZBTContractAdapter;
}
})();