diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 92c1a11..1d8e7c6 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -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 diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index c50a0d2..2773d1e 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 diff --git a/docs/provider-advertisement-contract.md b/docs/provider-advertisement-contract.md index 9dbeeac..a93d912 100644 --- a/docs/provider-advertisement-contract.md +++ b/docs/provider-advertisement-contract.md @@ -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. diff --git a/schemas/gamecult.eve.provider_advertisement.v1.schema.json b/schemas/gamecult.eve.provider_advertisement.v1.schema.json index b287811..f4d08b3 100644 --- a/schemas/gamecult.eve.provider_advertisement.v1.schema.json +++ b/schemas/gamecult.eve.provider_advertisement.v1.schema.json @@ -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 diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index bb4bcd6..2fb1e54 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -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", diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index 58f3edc..cda82df 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -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("
"); +} + function summarizeLifecycle(lifecycle) { if (!lifecycle) return ""; return ["release", "test", "capture"] diff --git a/web/fixtures/sai-vn.provider-advertisement.json b/web/fixtures/sai-vn.provider-advertisement.json new file mode 100644 index 0000000..a731428 --- /dev/null +++ b/web/fixtures/sai-vn.provider-advertisement.json @@ -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" + } + ] +}