Declare plugin sidecar ABI boundaries

This commit is contained in:
Meta Vens 2026-07-09 02:10:04 +01:00
commit 169dbfafd4
16 changed files with 310 additions and 4 deletions

View file

@ -280,6 +280,13 @@ Exit criteria:
Recently cut:
- Plugin manifests and advertisements now carry a runtime-independent sidecar
ABI boundary. Sai, Norn, and TeX declare `invocationModel:
executable-sidecar`, `gamecult.eve.plugin_abi.v1`, CultMesh/stdio transports,
renderer independence, no provider-state mutation, and provider-owned command
acceptance. The parity harness validates manifest/advertisement agreement,
exports the runtime boundary, and the plugin-owner consumer smoke asserts the
Sai/Norn sidecar contract from the conformance pack.
- Conformance export now carries first-class `capabilityGaps[]` records derived
from plugin errors, provider advertisement/scenario errors, runtime missing
evidence, declared unsupported plugin projection, capture gaps, and split

View file

@ -156,8 +156,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\run-plugin-handoff-smoke.ps1
That script validates the Sai and Norn handoff manifests against their plugin
manifests, advertisements, ABI fixtures, move-set paths, contract inputs, and
external proofs. The plugin-owner conformance smoke also asserts those handoff
paths from the conformance export.
external proofs. It also checks that the manifest and advertisement agree on
the runtime-independent executable sidecar boundary: `gamecult.eve.plugin_abi.v1`,
CultMesh/stdio transport, renderer independence, no provider-state mutation,
and provider-owned command acceptance. The plugin-owner conformance smoke also
asserts those handoff paths and runtime boundary claims from the conformance
export.
Aetheria provider handoff smoke:

View file

@ -114,6 +114,14 @@ not by importing plugin internals directly. A plugin implementation may use a
same-language library internally, but that is an implementation detail behind
the executable plugin boundary.
Plugin manifests and advertisements declare a `runtime` block. Its normal
incubating shape is `invocationModel: executable-sidecar`,
`contract: gamecult.eve.plugin_abi.v1`, CultMesh plus stdio transports, and
authority limits such as `renderer-independent`, `no-provider-state-mutation`,
and `provider-accepts-or-denies-commands`. Runtimes consume that advertisement
as a capability boundary. A Unity or web projection adapter may render an
advertised capability; it does not become the plugin runtime.
The ownership rule is semantic, not packaging ceremony: the plugin owns the
capability and representation shape, while providers own live state and
renderer clients own projection.

View file

@ -4,6 +4,16 @@
"ownerService": "norn",
"version": "0.1.0-incubating",
"manifestAddress": "file://plugins/incubating/norn-graph.plugin.json",
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"schemas": [
"norn.graph.document.v1",
"norn.graph.layout_request.v1",

View file

@ -24,6 +24,16 @@
"effect": "focus-graph"
}
],
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"stateSchemas": [
"norn.graph.document.v1",
"norn.graph.layout_request.v1",

View file

@ -4,6 +4,16 @@
"ownerService": "sai",
"version": "0.1.0-incubating",
"manifestAddress": "file://plugins/incubating/sai-vn.plugin.json",
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"schemas": [
"sai.vn.story_session.v1",
"sai.vn.visual_manifest.v1"

View file

@ -32,6 +32,16 @@
"effect": "jump-to-knot"
}
],
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"stateSchemas": [
"sai.vn.story_session.v1",
"sai.vn.visual_manifest.v1"

View file

@ -4,6 +4,16 @@
"ownerService": "eveplugins",
"version": "0.1.0-incubating",
"manifestAddress": "file://plugins/incubating/tex-math.plugin.json",
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"schemas": [
"tex.math.source.v1",
"tex.math.render_request.v1",

View file

@ -13,6 +13,16 @@
"embed.tex"
],
"commands": [],
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": [
"renderer-independent",
"no-provider-state-mutation",
"provider-accepts-or-denies-commands"
]
},
"stateSchemas": [
"tex.math.source.v1",
"tex.math.render_request.v1",

View file

@ -11,6 +11,7 @@
"incubation",
"componentKinds",
"commands",
"runtime",
"stateSchemas",
"fixtures",
"capabilities"
@ -92,6 +93,40 @@
"additionalProperties": true
}
},
"runtime": {
"type": "object",
"required": [
"invocationModel",
"contract",
"transports",
"authority"
],
"properties": {
"invocationModel": {
"type": "string",
"minLength": 1
},
"contract": {
"type": "string",
"minLength": 1
},
"transports": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"authority": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"additionalProperties": true
},
"stateSchemas": {
"type": "array",
"items": {

View file

@ -9,6 +9,7 @@
"ownerService",
"version",
"manifestAddress",
"runtime",
"schemas",
"componentKinds",
"commands",
@ -34,6 +35,40 @@
"type": "string",
"minLength": 1
},
"runtime": {
"type": "object",
"required": [
"invocationModel",
"contract",
"transports",
"authority"
],
"properties": {
"invocationModel": {
"type": "string",
"minLength": 1
},
"contract": {
"type": "string",
"minLength": 1
},
"transports": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"authority": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"additionalProperties": true
},
"schemas": {
"type": "array",
"items": {

View file

@ -65,6 +65,31 @@ foreach ($handoffPath in $HandoffPaths) {
if ($manifest.incubation.splitTarget -ne $handoff.splitTarget) {
throw "Plugin handoff $($handoff.pluginId) split target does not match manifest: $($manifest.incubation.splitTarget)"
}
if ($manifest.runtime.invocationModel -ne "executable-sidecar") {
throw "Plugin handoff $($handoff.pluginId) runtime is not executable-sidecar: $($manifest.runtime.invocationModel)"
}
if ($manifest.runtime.contract -ne "gamecult.eve.plugin_abi.v1") {
throw "Plugin handoff $($handoff.pluginId) runtime contract is not gamecult.eve.plugin_abi.v1: $($manifest.runtime.contract)"
}
if ($advertisement.runtime.invocationModel -ne $manifest.runtime.invocationModel) {
throw "Plugin handoff $($handoff.pluginId) advertisement runtime does not match manifest runtime: $($advertisement.runtime.invocationModel)"
}
foreach ($transport in @("cultmesh", "stdio")) {
if (-not (@($manifest.runtime.transports) -contains $transport)) {
throw "Plugin handoff $($handoff.pluginId) manifest runtime missing transport: $transport"
}
if (-not (@($advertisement.runtime.transports) -contains $transport)) {
throw "Plugin handoff $($handoff.pluginId) advertisement runtime missing transport: $transport"
}
}
foreach ($authority in @("renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands")) {
if (-not (@($manifest.runtime.authority) -contains $authority)) {
throw "Plugin handoff $($handoff.pluginId) manifest runtime missing authority: $authority"
}
if (-not (@($advertisement.runtime.authority) -contains $authority)) {
throw "Plugin handoff $($handoff.pluginId) advertisement runtime missing authority: $authority"
}
}
$operations = @($abiFixture.operations | ForEach-Object { $_.operation })
foreach ($operation in @("describe", "validate", "project", "lower", "measure", "apply")) {

View file

@ -54,7 +54,13 @@ node $consumerScript $consumerExport `
--expect-plugin-capability sai.vn:story.continue `
--expect-plugin-capability sai.vn:story.jump `
--expect-plugin-capability norn.graph:embed.norn `
--expect-plugin-capability norn.graph:graph.node.activate
--expect-plugin-capability norn.graph:graph.node.activate `
--expect-plugin-runtime sai.vn:executable-sidecar `
--expect-plugin-runtime-transport sai.vn:cultmesh `
--expect-plugin-runtime-authority sai.vn:no-provider-state-mutation `
--expect-plugin-runtime norn.graph:executable-sidecar `
--expect-plugin-runtime-transport norn.graph:cultmesh `
--expect-plugin-runtime-authority norn.graph:no-provider-state-mutation
if ($LASTEXITCODE -ne 0) {
throw "Plugin owner conformance consumer smoke failed with exit code $LASTEXITCODE"

View file

@ -21,6 +21,9 @@ if (!exportDirectory) {
" --expect-conformance-handoff",
" --expect-plugin-operation <pluginId:operation>",
" --expect-plugin-capability <pluginId:capability>",
" --expect-plugin-runtime <pluginId:invocationModel>",
" --expect-plugin-runtime-transport <pluginId:transport>",
" --expect-plugin-runtime-authority <pluginId:authority>",
" --expect-plugin-handoff <pluginId>",
" --expect-provider-surface <providerId:surfaceId>",
" --expect-provider-surface-kind <providerId:surfaceId:surfaceKind>",
@ -153,6 +156,36 @@ function validateIndex(index, directory, expectations, errors) {
errors.push(`plugins:${expectation.pluginId}:capability:${expectation.capability}:missing`);
}
}
for (const expectation of expectations.pluginRuntimes) {
const plugin = plugins.find(candidate => candidate.pluginId === expectation.pluginId);
if (!plugin) {
errors.push(`plugins:${expectation.pluginId}:missing`);
continue;
}
if (plugin.runtimeBoundary?.invocationModel !== expectation.invocationModel) {
errors.push(`plugins:${expectation.pluginId}:runtime:expected ${expectation.invocationModel} got ${plugin.runtimeBoundary?.invocationModel || ""}`);
}
}
for (const expectation of expectations.pluginRuntimeTransports) {
const plugin = plugins.find(candidate => candidate.pluginId === expectation.pluginId);
if (!plugin) {
errors.push(`plugins:${expectation.pluginId}:missing`);
continue;
}
if (!Array.isArray(plugin.runtimeBoundary?.transports) || !plugin.runtimeBoundary.transports.includes(expectation.transport)) {
errors.push(`plugins:${expectation.pluginId}:runtime.transport:${expectation.transport}:missing`);
}
}
for (const expectation of expectations.pluginRuntimeAuthorities) {
const plugin = plugins.find(candidate => candidate.pluginId === expectation.pluginId);
if (!plugin) {
errors.push(`plugins:${expectation.pluginId}:missing`);
continue;
}
if (!Array.isArray(plugin.runtimeBoundary?.authority) || !plugin.runtimeBoundary.authority.includes(expectation.authority)) {
errors.push(`plugins:${expectation.pluginId}:runtime.authority:${expectation.authority}:missing`);
}
}
for (const expectedPlugin of expectations.pluginHandoffs) {
const plugin = plugins.find(candidate => candidate.pluginId === expectedPlugin);
if (!plugin) {
@ -422,6 +455,9 @@ function parseArguments(args) {
plugins: [],
pluginOperations: [],
pluginCapabilities: [],
pluginRuntimes: [],
pluginRuntimeTransports: [],
pluginRuntimeAuthorities: [],
pluginHandoffs: [],
providers: [],
providerSurfaces: [],
@ -454,6 +490,9 @@ function parseArguments(args) {
["--expect-split-target", expectations.splitTargets],
["--expect-plugin-operation", expectations.pluginOperations],
["--expect-plugin-capability", expectations.pluginCapabilities],
["--expect-plugin-runtime", expectations.pluginRuntimes],
["--expect-plugin-runtime-transport", expectations.pluginRuntimeTransports],
["--expect-plugin-runtime-authority", expectations.pluginRuntimeAuthorities],
["--expect-plugin-handoff", expectations.pluginHandoffs],
["--expect-provider-surface", expectations.providerSurfaces],
["--expect-provider-surface-kind", expectations.providerSurfaceKinds],
@ -495,6 +534,12 @@ function parseArguments(args) {
target.push(parsePluginExpectation(value, "operation"));
} else if (option === "--expect-plugin-capability") {
target.push(parsePluginExpectation(value, "capability"));
} else if (option === "--expect-plugin-runtime") {
target.push(parsePluginExpectation(value, "invocationModel"));
} else if (option === "--expect-plugin-runtime-transport") {
target.push(parsePluginExpectation(value, "transport"));
} else if (option === "--expect-plugin-runtime-authority") {
target.push(parsePluginExpectation(value, "authority"));
} else if (option === "--expect-plugin-handoff") {
target.push(value);
} else if (option === "--expect-provider-surface") {

View file

@ -372,6 +372,12 @@
"graduationTrigger": "Split when VN/Ink semantics, story command fixtures, and ABI project/apply/validate cases are stable.",
"capabilities": ["vn.stage", "story.choose", "story.continue", "story.jump"],
"optionalPlugins": ["norn.graph", "tex.math"],
"expectedRuntime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"]
},
"requiredFixtures": ["sai-vn"],
"schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json",
"manifestPath": "plugins/incubating/sai-vn.plugin.json",
@ -396,6 +402,12 @@
"splitTarget": "Norn/eve-plugin-graph",
"graduationTrigger": "Split when embed.norn graph layout, interaction, fallback, and conformance fixtures are stable.",
"capabilities": ["embed.norn", "graph.node.activate"],
"expectedRuntime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"]
},
"requiredFixtures": ["sai-vn"],
"schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json",
"manifestPath": "plugins/incubating/norn-graph.plugin.json",
@ -420,6 +432,12 @@
"splitTarget": "EvePlugins/eve-plugin-tex",
"graduationTrigger": "Split when TeX source, macro, baseline, cached render, and fallback fixtures are stable.",
"capabilities": ["embed.tex", "tex.inline", "tex.block"],
"expectedRuntime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultmesh", "stdio"],
"authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"]
},
"requiredFixtures": ["sai-vn"],
"schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json",
"manifestPath": "plugins/incubating/tex-math.plugin.json",

View file

@ -443,6 +443,8 @@ async function evaluatePlugin(plugin, fixtureResults) {
schema: "gamecult.eve.plugin_advertisement.v1",
pluginId: plugin.pluginId,
});
const runtimeBoundary = await readPluginRuntimeBoundary(plugin);
const runtimeBoundaryErrors = validatePluginRuntimeBoundary(plugin, runtimeBoundary);
const abiOperations = await readPluginAbiOperations(plugin.abiFixturePath);
const abiErrors = await validatePluginAbiFixture(plugin);
const status = missingPaths.length
@ -455,7 +457,9 @@ async function evaluatePlugin(plugin, fixtureResults) {
? "invalid-plugin-manifest"
: advertisementErrors.length
? "invalid-plugin-advertisement"
: abiErrors.length
: runtimeBoundaryErrors.length
? "invalid-plugin-runtime-boundary"
: abiErrors.length
? "invalid-plugin-abi-fixture"
: plugin.kind === "incubating"
? "incubating"
@ -471,6 +475,8 @@ async function evaluatePlugin(plugin, fixtureResults) {
splitTarget: plugin.splitTarget || "",
capabilities: plugin.capabilities || [],
optionalPlugins: plugin.optionalPlugins || [],
runtimeBoundary,
runtimeBoundaryErrors,
requiredFixtures,
missingRequiredFixtures,
schemaPath: plugin.schemaPath || "",
@ -503,6 +509,60 @@ async function readPluginAbiOperations(abiFixturePath) {
}
}
async function readPluginRuntimeBoundary(plugin) {
try {
const manifestDocument = await readJsonDocument(plugin.manifestPath);
const advertisementDocument = await readJsonDocument(plugin.advertisementPath);
return {
invocationModel: manifestDocument.runtime?.invocationModel || "",
contract: manifestDocument.runtime?.contract || "",
transports: manifestDocument.runtime?.transports || [],
authority: manifestDocument.runtime?.authority || [],
advertisementInvocationModel: advertisementDocument.runtime?.invocationModel || "",
advertisementContract: advertisementDocument.runtime?.contract || "",
advertisementTransports: advertisementDocument.runtime?.transports || [],
advertisementAuthority: advertisementDocument.runtime?.authority || [],
};
} catch {
return {
invocationModel: "",
contract: "",
transports: [],
authority: [],
advertisementInvocationModel: "",
advertisementContract: "",
advertisementTransports: [],
advertisementAuthority: [],
};
}
}
function validatePluginRuntimeBoundary(plugin, runtimeBoundary) {
const errors = [];
const expectedRuntime = plugin.expectedRuntime || {};
if (!runtimeBoundary.invocationModel) errors.push("runtime.invocationModel:missing");
if (!runtimeBoundary.contract) errors.push("runtime.contract:missing");
if (!runtimeBoundary.transports?.length) errors.push("runtime.transports:missing");
if (!runtimeBoundary.authority?.length) errors.push("runtime.authority:missing");
if (runtimeBoundary.invocationModel !== runtimeBoundary.advertisementInvocationModel) {
errors.push(`runtime.invocationModel:advertisement:expected ${runtimeBoundary.invocationModel} got ${runtimeBoundary.advertisementInvocationModel}`);
}
if (runtimeBoundary.contract !== runtimeBoundary.advertisementContract) {
errors.push(`runtime.contract:advertisement:expected ${runtimeBoundary.contract} got ${runtimeBoundary.advertisementContract}`);
}
if (expectedRuntime.invocationModel && runtimeBoundary.invocationModel !== expectedRuntime.invocationModel) {
errors.push(`runtime.invocationModel:expected ${expectedRuntime.invocationModel} got ${runtimeBoundary.invocationModel}`);
}
if (expectedRuntime.contract && runtimeBoundary.contract !== expectedRuntime.contract) {
errors.push(`runtime.contract:expected ${expectedRuntime.contract} got ${runtimeBoundary.contract}`);
}
errors.push(...missingMembers(expectedRuntime.transports || [], runtimeBoundary.transports || [], "runtime.transports"));
errors.push(...missingMembers(expectedRuntime.transports || [], runtimeBoundary.advertisementTransports || [], "runtime.advertisementTransports"));
errors.push(...missingMembers(expectedRuntime.authority || [], runtimeBoundary.authority || [], "runtime.authority"));
errors.push(...missingMembers(expectedRuntime.authority || [], runtimeBoundary.advertisementAuthority || [], "runtime.advertisementAuthority"));
return errors;
}
async function evaluateFixtureMetadata(fixture) {
const errors = [];
if (!fixture.metadataPath) return { metadata: null, errors: ["metadataPath:missing"] };
@ -1399,6 +1459,7 @@ function buildConformanceExport(report) {
handoffExportPath: makeHandoffExportPath("plugin", plugin.pluginId, plugin.handoffPath),
abiFixturePath: plugin.abiFixturePath,
abiOperations: plugin.abiOperations || [],
runtimeBoundary: plugin.runtimeBoundary,
optionalPlugins: plugin.optionalPlugins || [],
capabilities: plugin.capabilities,
})),
@ -1531,6 +1592,7 @@ function collectCapabilityGaps(report) {
...(plugin.missingIncubationFields || []).map(id => `metadata:${id}`),
...(plugin.schemaErrors || []).map(id => `schema:${id}`),
...(plugin.advertisementErrors || []).map(id => `advertisement:${id}`),
...(plugin.runtimeBoundaryErrors || []).map(id => `runtime-boundary:${id}`),
...(plugin.abiErrors || []).map(id => `abi:${id}`),
]) {
addGap({
@ -1793,6 +1855,7 @@ function renderMarkdown(report) {
...plugin.missingIncubationFields.map(id => `metadata:${id}`),
...plugin.schemaErrors.map(id => `schema:${id}`),
...plugin.advertisementErrors.map(id => `advertisement:${id}`),
...plugin.runtimeBoundaryErrors.map(id => `runtime-boundary:${id}`),
...plugin.abiErrors.map(id => `abi:${id}`),
].join(", ");
lines.push(`| ${plugin.title || plugin.pluginId} | ${plugin.status} | ${plugin.ownerRepo} | ${(plugin.abiOperations || []).join(", ")} | ${plugin.abiFixturePath || ""} | ${plugin.capabilities.join(", ")} | ${missing} |`);