Validate provider plugin requirements

This commit is contained in:
Meta Vens 2026-07-09 01:10:32 +01:00
commit 5839ae2acb
7 changed files with 222 additions and 5 deletions

View file

@ -280,6 +280,13 @@ Exit criteria:
Recently cut:
- Provider advertisements can now name sidecar plugin requirements per surface
with `surfaces[].requiresPlugins[]`. The parity harness validates those
requirements against known plugin manifests, exports them in the conformance
provider list, and reports missing plugins or capabilities as provider
capability gaps. The Sai VN fixture now has an advertisement-shaped provider
entry for `gamecult.home.vn`, so Sai/Norn/TeX requirements sit at the
provider/plugin boundary instead of hiding only in fixture metadata.
- The conformance runtime pack now exports `runtimeTargets` in
`artifacts/conformance/latest/packs/runtime.json`. Runtime repos can consume
runtime status, feature/plugin claims, command schema, lifecycle, capture

View file

@ -307,6 +307,12 @@ Recommended discovery path:
6. Unsupported required plugin capability becomes a visible capability gap.
7. Optional unsupported capability becomes a visible degraded lowering.
Eve's parity harness validates this boundary directly: provider-advertised
`surfaces[].requiresPlugins[]` entries must name known plugin manifests, and
each `requiredCapabilities` item must be claimed by that plugin manifest. This
keeps plugin requirements in the provider advertisement instead of hiding them
in runtime lowerers or fixture-only metadata.
Provider advertisement sketch:
```json

View file

@ -72,6 +72,10 @@ Required top-level meanings:
- `witnesses`: CultCache `.cc` files, database exports, or document keys that
witness durable state.
- `surfaces`: Eve surface documents or endpoints the provider publishes.
- `surfaces[].requiresPlugins`: sidecar plugin capabilities required to render
or interact with that surface. Runtimes compare these requirements with their
local projection support and report visible capability gaps; they must not
import provider or plugin internals to recover missing semantics.
- `commands`: command boundaries the provider accepts.
- `routes`: transport, store, or compatibility routes used to reach the
semantic address. WebSocket and HTTP routes live here when they still exist.

View file

@ -135,6 +135,37 @@
"status": {
"type": "string",
"minLength": 1
},
"requiresPlugins": {
"type": "array",
"items": {
"type": "object",
"required": ["pluginId", "requiredCapabilities"],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"versionRange": {
"type": "string"
},
"requiredCapabilities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"optionalCapabilities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"additionalProperties": true
}
}
},
"additionalProperties": true

View file

@ -296,6 +296,20 @@
"expectedSurfaces": ["repixelizer.operator.surface"],
"expectedCommands": ["repixelizer.job.submit", "repixelizer.job.cancel_own"]
},
{
"providerId": "gamecult.home.vn",
"title": "GameCult Compound VN",
"kind": "plugin.fixture.provider",
"ownerRepo": "Sai",
"repoRole": "provider-fixture",
"graduationTrigger": "Move this advertisement-shaped VN fixture to Sai when sai-vn-plugin owns VN/Ink semantics and conformance packs.",
"schemaPath": "schemas/gamecult.eve.provider_advertisement.v1.schema.json",
"advertisementPath": "web/fixtures/sai-vn.provider-advertisement.json",
"requiredFixtures": ["sai-vn"],
"expectedSchemas": ["gamecult.eve.surface.v1", "gamecult.eve.command.v1", "sai.vn.story_session.v1"],
"expectedSurfaces": ["sai.visual_novel.surface"],
"expectedCommands": ["story.continue", "story.choose", "story.jump", "style.patch"]
},
{
"providerId": "aetheria",
"title": "Aetheria",

View file

@ -25,7 +25,7 @@ for (const fixture of manifest.fixtures) {
}
const pluginResults = await Promise.all((manifest.pluginManifests || []).map(plugin => evaluatePlugin(plugin, fixtureResults)));
const providerResults = await Promise.all((manifest.providerAdvertisements || []).map(provider => evaluateProvider(provider, fixtureResults)));
const providerResults = await Promise.all((manifest.providerAdvertisements || []).map(provider => evaluateProvider(provider, fixtureResults, pluginResults)));
const runtimeResults = await Promise.all(manifest.runtimes.map(runtime => evaluateRuntime(runtime, fixtureResults)));
const splitTargetResults = evaluateSplitTargets(manifest.splitTargets || [], runtimeResults);
const report = {
@ -462,7 +462,7 @@ async function evaluateFixtureMetadata(fixture) {
return { metadata, errors };
}
async function evaluateProvider(provider, fixtureResults) {
async function evaluateProvider(provider, fixtureResults, pluginResults) {
const expectedPaths = provider.expectedPaths || [];
const missingPaths = expectedPaths.filter(candidate => !existsSync(path.join(repoRoot, candidate)));
const requiredFixtures = provider.requiredFixtures || [];
@ -482,6 +482,8 @@ async function evaluateProvider(provider, fixtureResults) {
const surfaceIds = advertisement ? (advertisement.surfaces || []).map(surface => surface.surfaceId).filter(Boolean).sort() : [];
const commandIds = advertisement ? (advertisement.commands || []).map(command => command.command).filter(Boolean).sort() : [];
const witnessKinds = advertisement ? (advertisement.witnesses || []).map(witness => witness.kind).filter(Boolean).sort() : [];
const pluginRequirements = advertisement ? collectProviderPluginRequirements(advertisement) : [];
const pluginRequirementErrors = validateProviderPluginRequirements(provider, pluginRequirements, pluginResults);
const missingSchemas = (provider.expectedSchemas || []).filter(schema => !advertisedSchemaIds.includes(schema));
const missingSurfaces = (provider.expectedSurfaces || []).filter(surface => !surfaceIds.includes(surface));
const missingCommands = (provider.expectedCommands || []).filter(command => !commandIds.includes(command));
@ -495,7 +497,7 @@ async function evaluateProvider(provider, fixtureResults) {
? "missing-ownership-metadata"
: advertisementErrors.length
? "invalid-provider-advertisement"
: missingSchemas.length || missingSurfaces.length || missingCommands.length
: missingSchemas.length || missingSurfaces.length || missingCommands.length || pluginRequirementErrors.length
? "capability-gap"
: scenarioErrors.length
? "invalid-provider-scenario"
@ -526,6 +528,8 @@ async function evaluateProvider(provider, fixtureResults) {
surfaceIds,
commandIds,
witnessKinds,
pluginRequirements,
pluginRequirementErrors,
missingSchemas,
missingSurfaces,
missingCommands,
@ -535,6 +539,45 @@ async function evaluateProvider(provider, fixtureResults) {
};
}
function collectProviderPluginRequirements(advertisement) {
const requirements = [];
for (const surface of advertisement.surfaces || []) {
for (const requirement of surface.requiresPlugins || []) {
requirements.push({
surfaceId: surface.surfaceId || "",
pluginId: requirement.pluginId || "",
versionRange: requirement.versionRange || "",
requiredCapabilities: requirement.requiredCapabilities || [],
optionalCapabilities: requirement.optionalCapabilities || [],
});
}
}
return requirements;
}
function validateProviderPluginRequirements(provider, requirements, pluginResults) {
const errors = [];
const pluginById = new Map((pluginResults || []).map(plugin => [plugin.pluginId, plugin]));
for (const requirement of requirements) {
const plugin = pluginById.get(requirement.pluginId);
if (!plugin) {
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:missing`);
continue;
}
if (plugin.status !== "incubating" && plugin.status !== "external-owner-planned") {
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:status:${plugin.status}`);
continue;
}
const capabilities = new Set(plugin.capabilities || []);
for (const capability of requirement.requiredCapabilities || []) {
if (!capabilities.has(capability)) {
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:${capability}:missing`);
}
}
}
return errors;
}
async function readJsonDocument(documentPath) {
return JSON.parse(await readFile(path.join(repoRoot, documentPath), "utf8"));
}
@ -1244,6 +1287,7 @@ function buildConformanceExport(report) {
scenarioId: provider.scenarioId,
surfaces: provider.surfaceIds,
commands: provider.commandIds,
pluginRequirements: provider.pluginRequirements,
})),
runtimes: (report.runtimes || []).map(runtime => ({
runtimeId: runtime.id,
@ -1393,7 +1437,7 @@ function renderMarkdown(report) {
lines.push(`| ${plugin.title || plugin.pluginId} | ${plugin.status} | ${plugin.ownerRepo} | ${(plugin.abiOperations || []).join(", ")} | ${plugin.abiFixturePath || ""} | ${plugin.capabilities.join(", ")} | ${missing} |`);
}
lines.push("", "## Providers", "", "| Provider | Status | Owner | Scenario | Receipt States | Surfaces | Commands | Witnesses | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |");
lines.push("", "## Providers", "", "| Provider | Status | Owner | Scenario | Receipt States | Surfaces | Commands | Plugin Requirements | Witnesses | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
for (const provider of report.providers || []) {
const missing = [
...provider.missingPaths,
@ -1404,8 +1448,9 @@ function renderMarkdown(report) {
...provider.missingSchemas.map(id => `schema:${id}`),
...provider.missingSurfaces.map(id => `surface:${id}`),
...provider.missingCommands.map(id => `command:${id}`),
...provider.pluginRequirementErrors.map(id => `plugin:${id}`),
].join(", ");
lines.push(`| ${provider.title || provider.providerId} | ${provider.status} | ${provider.ownerRepo} | ${provider.scenarioId || ""} | ${provider.scenarioReceiptStates.join(", ")} | ${provider.surfaceIds.join(", ")} | ${provider.commandIds.join(", ")} | ${provider.witnessKinds.join(", ")} | ${missing} |`);
lines.push(`| ${provider.title || provider.providerId} | ${provider.status} | ${provider.ownerRepo} | ${provider.scenarioId || ""} | ${provider.scenarioReceiptStates.join(", ")} | ${provider.surfaceIds.join(", ")} | ${provider.commandIds.join(", ")} | ${summarizeProviderPluginRequirements(provider.pluginRequirements)} | ${provider.witnessKinds.join(", ")} | ${missing} |`);
}
lines.push("", "## Runtimes", "", "| Runtime | Status | Owner | Lifecycle | Capture | Command Smoke | Required Fixtures | Plugin Fixtures | Features | Plugin Gaps | Unsupported Plugins | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
@ -1443,6 +1488,12 @@ function renderMarkdown(report) {
return `${lines.join("\n")}\n`;
}
function summarizeProviderPluginRequirements(requirements = []) {
return requirements
.map(requirement => `${requirement.surfaceId}:${requirement.pluginId}(${(requirement.requiredCapabilities || []).join(", ")})`)
.join("<br>");
}
function summarizeLifecycle(lifecycle) {
if (!lifecycle) return "";
return ["release", "test", "capture"]

View file

@ -0,0 +1,104 @@
{
"schema": "gamecult.eve.provider_advertisement.v1",
"providerId": "gamecult.home.vn",
"serviceId": "gamecult.home.vn.service",
"verseId": "gamecult.home.local",
"rootVerse": "asgard",
"canonicalService": "gamecult.home.vn",
"locatedService": "gamecult.home.local.vn",
"cultMeshAddress": "cultmesh://gamecult.home.local/eve/providers/gamecult.home.vn",
"title": "GameCult Compound VN",
"kind": "plugin.fixture.provider",
"updatedAtUtc": "2026-07-08T00:00:00Z",
"freshness": {
"state": "fixture",
"lastSeenAtUtc": "2026-07-08T00:00:00Z",
"maxAgeMs": 15000
},
"schemas": [
"gamecult.eve.surface.v1",
"gamecult.eve.command.v1",
"sai.vn.story_session.v1",
"sai.vn.visual_manifest.v1",
"norn.graph.document.v1",
"tex.math.source.v1"
],
"witnesses": [
{
"kind": "plugin-fixture",
"ref": "web/fixtures/sai-vn-surface.json",
"summary": "Advertisement-shaped provider entry for the Sai/Norn/TeX plugin conformance fixture."
},
{
"kind": "owner-repo",
"ref": "Sai",
"summary": "Sai owns VN/Ink semantics once the plugin ABI graduates; Eve only hosts this fixture while the contract is forming."
}
],
"surfaces": [
{
"schema": "gamecult.eve.surface.v1",
"surfaceId": "sai.visual_novel.surface",
"key": "web/fixtures/sai-vn-surface.json",
"transport": "local-json",
"status": "available",
"audience": "player",
"mode": "interactive",
"summary": "Visual novel surface requiring sidecar plugin capabilities for VN stage, Norn graph embedding, and TeX embedding.",
"requiresPlugins": [
{
"pluginId": "sai.vn",
"versionRange": "^0.1.0",
"requiredCapabilities": [
"vn.stage",
"story.choose",
"story.continue",
"story.jump"
]
},
{
"pluginId": "norn.graph",
"versionRange": "^0.1.0",
"requiredCapabilities": [
"embed.norn"
]
},
{
"pluginId": "tex.math",
"versionRange": "^0.1.0",
"requiredCapabilities": [
"embed.tex"
]
}
]
}
],
"commands": [
{
"command": "story.continue",
"transport": "plugin-abi",
"summary": "Advance story through Sai's sidecar plugin ABI."
},
{
"command": "story.choose",
"transport": "plugin-abi",
"summary": "Choose a story branch through Sai's sidecar plugin ABI."
},
{
"command": "story.jump",
"transport": "plugin-abi",
"summary": "Jump to a story path through Sai's sidecar plugin ABI."
},
{
"command": "style.patch",
"transport": "eve-command",
"summary": "Patch local style tokens without moving story truth into Eve."
}
],
"contacts": [
{
"kind": "repo",
"path": "E:\\Projects\\Sai"
}
]
}