Export plugin ABI operation contracts

This commit is contained in:
Meta Vens 2026-07-09 02:49:45 +01:00
commit 8d9f78e107
6 changed files with 105 additions and 1 deletions

View file

@ -304,6 +304,10 @@ Recently cut:
can rely on exported ids, owners, statuses, split targets, manifest or
advertisement paths, feature/plugin arrays, and blocker/proof arrays instead
of treating those sections as untyped bags.
- Plugin ABI fixture operations are now exported as
`plugins[].abiOperationContracts`. Sai and Norn can consume operation-level
`input` and `expect` contracts for `describe`, `validate`, `project`,
`lower`, `measure`, and `apply` without reading Eve-local fixture files.
- EveUnity now has a pending Unity scene handoff at
`runtimes/incubating/eve-unity-scene/eveunity-scene-split-handoff.json`.
The active `unity-uitoolkit` runtime remains a semantic UI surface lowerer;

View file

@ -123,6 +123,10 @@ Plugin handoff paths are exported as `plugins[].handoffPath` for owner repos
such as Sai and Norn. These paths are incubation evidence for where plugin
manifest, advertisement, ABI fixture, and conformance ownership must move; they
are not plugin runtime state.
Plugin ABI fixture operations are exported as `plugins[].abiOperationContracts`
with `operation`, `input`, and `expect` blocks. Plugin owners can consume the
operation-level contract for `describe`, `validate`, `project`, `lower`,
`measure`, and `apply` without opening Eve-local fixture files.
The runtime conformance pack is evidence-shaped, not fixture-shaped. Its
`packs/runtime.json` document includes `runtimeTargets` so runtime repos can

View file

@ -206,6 +206,30 @@
"abiOperations": {
"type": "array"
},
"abiOperationContracts": {
"type": "array",
"items": {
"type": "object",
"required": [
"operation",
"input",
"expect"
],
"properties": {
"operation": {
"type": "string",
"minLength": 1
},
"input": {
"type": "object"
},
"expect": {
"type": "object"
}
},
"additionalProperties": true
}
},
"runtimeBoundary": {
"type": "object"
},

View file

@ -63,6 +63,8 @@ node $consumerScript $consumerExport `
--expect-plugin-runtime-field sai.vn:sidecar.requestSchema:gamecult.eve.plugin_abi.request.v1 `
--expect-plugin-runtime-field sai.vn:sidecar.responseSchema:gamecult.eve.plugin_abi.response.v1 `
--expect-plugin-runtime-field sai.vn:sidecar.stateAuthority:proposes-plugin-state-only-provider-accepts `
--expect-plugin-abi-field sai.vn:lower:expect.commandEnvelope:gamecult.eve.command.v1 `
--expect-plugin-abi-field sai.vn:apply:expect.receiptSchema:gamecult.eve.command_receipt.v1 `
--expect-plugin-runtime norn.graph:executable-sidecar `
--expect-plugin-runtime-transport norn.graph:cultmesh `
--expect-plugin-runtime-authority norn.graph:no-provider-state-mutation `
@ -70,7 +72,9 @@ node $consumerScript $consumerExport `
--expect-plugin-runtime-field norn.graph:sidecar.protocol:cultmesh-rpc-with-stdio-dev-transport `
--expect-plugin-runtime-field norn.graph:sidecar.requestSchema:gamecult.eve.plugin_abi.request.v1 `
--expect-plugin-runtime-field norn.graph:sidecar.responseSchema:gamecult.eve.plugin_abi.response.v1 `
--expect-plugin-runtime-field norn.graph:sidecar.stateAuthority:proposes-plugin-state-only-provider-accepts
--expect-plugin-runtime-field norn.graph:sidecar.stateAuthority:proposes-plugin-state-only-provider-accepts `
--expect-plugin-abi-field norn.graph:lower:expect.commandEnvelope:gamecult.eve.command.v1 `
--expect-plugin-abi-field norn.graph:apply:expect.receiptSchema:gamecult.eve.command_receipt.v1
if ($LASTEXITCODE -ne 0) {
throw "Plugin owner conformance consumer smoke failed with exit code $LASTEXITCODE"

View file

@ -26,6 +26,7 @@ if (!exportDirectory) {
" --expect-plugin-runtime-transport <pluginId:transport>",
" --expect-plugin-runtime-authority <pluginId:authority>",
" --expect-plugin-runtime-field <pluginId:field.path:value>",
" --expect-plugin-abi-field <pluginId:operation:field.path:value>",
" --expect-plugin-handoff <pluginId>",
" --expect-provider-surface <providerId:surfaceId>",
" --expect-provider-surface-kind <providerId:surfaceId:surfaceKind>",
@ -228,6 +229,23 @@ function validateIndex(index, directory, expectations, errors) {
errors.push(`plugins:${expectation.pluginId}:runtime.${expectation.fieldPath}:expected ${expectation.value} got ${actual || ""}`);
}
}
for (const expectation of expectations.pluginAbiFields) {
const plugin = plugins.find(candidate => candidate.pluginId === expectation.pluginId);
if (!plugin) {
errors.push(`plugins:${expectation.pluginId}:missing`);
continue;
}
const contracts = Array.isArray(plugin.abiOperationContracts) ? plugin.abiOperationContracts : [];
const contract = contracts.find(candidate => candidate.operation === expectation.operation);
if (!contract) {
errors.push(`plugins:${expectation.pluginId}:abiOperation:${expectation.operation}:missing`);
continue;
}
const actual = readNestedField(contract, expectation.fieldPath);
if (actual !== expectation.value) {
errors.push(`plugins:${expectation.pluginId}:abiOperation:${expectation.operation}.${expectation.fieldPath}:expected ${expectation.value} got ${actual || ""}`);
}
}
for (const expectedPlugin of expectations.pluginHandoffs) {
const plugin = plugins.find(candidate => candidate.pluginId === expectedPlugin);
if (!plugin) {
@ -580,6 +598,18 @@ function validatePluginRecords(plugins, errors) {
for (const field of ["capabilities", "abiOperations", "optionalPlugins"]) {
if (!Array.isArray(plugin?.[field])) errors.push(`${label}:${field}:expected array`);
}
if (!Array.isArray(plugin?.abiOperationContracts)) {
errors.push(`${label}:abiOperationContracts:expected array`);
}
for (const contract of plugin?.abiOperationContracts || []) {
if (!contract.operation) errors.push(`${label}:abiOperationContracts:operation:missing`);
if (!contract.input || typeof contract.input !== "object" || Array.isArray(contract.input)) {
errors.push(`${label}:abiOperationContracts:${contract.operation || "unknown"}:input:expected object`);
}
if (!contract.expect || typeof contract.expect !== "object" || Array.isArray(contract.expect)) {
errors.push(`${label}:abiOperationContracts:${contract.operation || "unknown"}:expect:expected object`);
}
}
}
}
@ -642,6 +672,7 @@ function parseArguments(args) {
pluginRuntimeTransports: [],
pluginRuntimeAuthorities: [],
pluginRuntimeFields: [],
pluginAbiFields: [],
pluginHandoffs: [],
providers: [],
providerSurfaces: [],
@ -685,6 +716,7 @@ function parseArguments(args) {
["--expect-plugin-runtime-transport", expectations.pluginRuntimeTransports],
["--expect-plugin-runtime-authority", expectations.pluginRuntimeAuthorities],
["--expect-plugin-runtime-field", expectations.pluginRuntimeFields],
["--expect-plugin-abi-field", expectations.pluginAbiFields],
["--expect-plugin-handoff", expectations.pluginHandoffs],
["--expect-provider-surface", expectations.providerSurfaces],
["--expect-provider-surface-kind", expectations.providerSurfaceKinds],
@ -741,6 +773,8 @@ function parseArguments(args) {
target.push(parsePluginExpectation(value, "authority"));
} else if (option === "--expect-plugin-runtime-field") {
target.push(parsePluginRuntimeFieldExpectation(value));
} else if (option === "--expect-plugin-abi-field") {
target.push(parsePluginAbiFieldExpectation(value));
} else if (option === "--expect-plugin-handoff") {
target.push(value);
} else if (option === "--expect-provider-surface") {
@ -832,6 +866,20 @@ function parsePluginRuntimeFieldExpectation(value) {
};
}
function parsePluginAbiFieldExpectation(value) {
const parts = value.split(":");
if (parts.length < 4 || !parts[0] || !parts[1] || !parts[2] || !parts.slice(3).join(":")) {
console.error(`Expected plugin ABI field in <pluginId:operation:field.path:value> form, got: ${value}`);
process.exit(2);
}
return {
pluginId: parts[0],
operation: parts[1],
fieldPath: parts[2],
value: parts.slice(3).join(":"),
};
}
function parseProviderSurfaceFieldExpectation(value) {
const parts = value.split(":");
if (parts.length < 4 || !parts[0] || !parts[1] || !parts[2] || !parts.slice(3).join(":")) {

View file

@ -449,6 +449,7 @@ async function evaluatePlugin(plugin, fixtureResults) {
const runtimeBoundary = await readPluginRuntimeBoundary(plugin);
const runtimeBoundaryErrors = validatePluginRuntimeBoundary(plugin, runtimeBoundary);
const abiOperations = await readPluginAbiOperations(plugin.abiFixturePath);
const abiOperationContracts = await readPluginAbiOperationContracts(plugin.abiFixturePath);
const abiErrors = await validatePluginAbiFixture(plugin);
const status = missingPaths.length
? "missing-body"
@ -487,6 +488,7 @@ async function evaluatePlugin(plugin, fixtureResults) {
abiFixturePath: plugin.abiFixturePath || "",
handoffPath: plugin.handoffPath || "",
abiOperations,
abiOperationContracts,
advertisementSchemaPath: plugin.advertisementSchemaPath || "",
advertisementPath: plugin.advertisementPath || "",
schemaErrors,
@ -512,6 +514,23 @@ async function readPluginAbiOperations(abiFixturePath) {
}
}
async function readPluginAbiOperationContracts(abiFixturePath) {
if (!abiFixturePath) return [];
try {
const abiFixture = await readJsonDocument(abiFixturePath);
return (abiFixture.operations || [])
.filter(operation => operation.operation)
.map(operation => ({
operation: operation.operation,
input: operation.input || {},
expect: operation.expect || {},
}))
.sort((left, right) => left.operation.localeCompare(right.operation));
} catch {
return [];
}
}
async function readPluginRuntimeBoundary(plugin) {
try {
const manifestDocument = await readJsonDocument(plugin.manifestPath);
@ -1602,6 +1621,7 @@ function buildConformanceExport(report) {
handoffExportPath: makeHandoffExportPath("plugin", plugin.pluginId, plugin.handoffPath),
abiFixturePath: plugin.abiFixturePath,
abiOperations: plugin.abiOperations || [],
abiOperationContracts: plugin.abiOperationContracts || [],
runtimeBoundary: plugin.runtimeBoundary,
optionalPlugins: plugin.optionalPlugins || [],
capabilities: plugin.capabilities,