diff --git a/README.md b/README.md index f55d552..b28a778 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - CultCache-backed governance topic threads for feature requests, discussion comments, Face approvals, and dispatch promotion - CultCache/CultNet-backed agent intake tooling for repo Face update requests - a Verse service contract for CultCache witnesses, CultMesh namespaces, and Eve/CultUI product and operator surfaces +- a read-only Eve provider advertisement export command for Bifrost Verse surface discovery - Docker image and local Compose stack for containerized smoke testing - xUnit integration tests under `tests/Bifrost.Web.Tests` - matching deploy artifacts and runbooks in `E:\Projects\gamecult-ops` @@ -69,6 +70,8 @@ For the full staged roadmap, including contribution points, revenue share, patro - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` - test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed +- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` +- export the Eve provider advertisement witness with `node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc` - container smoke test with `docker compose -f compose.local.yaml up --build` - container health checks live at `http://127.0.0.1:5080/healthz` and `http://127.0.0.1:5080/readyz` diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 615e5e1..c90d816 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -71,6 +71,30 @@ Bifrost publishes under these CultMesh namespaces: Odin discovers Bifrost through the service namespace, then indexes the schema catalog and surface namespaces. Odin does not scrape Razor pages as truth. +## Provider Advertisement Export + +Bifrost has a read-only first-cut provider advertisement command: + +```powershell +node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc +``` + +It writes a single `gamecult.eve.provider_advertisement.v1` CultCache document +for Odin/Eve discovery experiments. The document names Bifrost's Account, +Patron, Project, Work, Motion, and Operator Verse surfaces; current and planned +schema ids; `.cc` witness/export paths; command authority boundaries; style +capabilities; and demoted presentation/probe surfaces. + +This export is discovery metadata only. It does not migrate Postgres state, +publish to CultMesh, read secrets, execute bridge actions, or make Razor Pages, +HTTP probes, Discord mirrors, or local dispatch JSON canonical. + +For protocol-debug inspection without writing a witness: + +```powershell +node tools/provider-advertisement.mjs print +``` + ## Eve Product And Operator Surfaces Product surfaces are the canonical interface compositions for: diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs new file mode 100644 index 0000000..75e1406 --- /dev/null +++ b/tools/provider-advertisement.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const projectsRoot = resolve(repoRoot, ".."); +const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-advertisement.cc"); + +const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json")); +const { + CultCache, + SingleFileMessagePackBackingStore, + defineDocumentType, +} = cultCacheRequire(resolve(projectsRoot, "CultCacheTS", "dist", "index.js")); + +const documentType = "gamecult.eve.provider_advertisement"; +const schemaId = "gamecult.eve.provider_advertisement.v1"; +const documentId = "bifrost"; + +const advertisementDefinition = defineDocumentType({ + type: documentType, + schemaId, + schemaName: documentType, + schemaVersion: schemaId, + schema: { + parse: parseAdvertisement, + }, + name: "providerId", + indexes: { + serviceName: "serviceName", + }, + members: [ + { slot: 1, memberName: "providerId", typeName: "string", isName: true }, + { slot: 2, memberName: "serviceName", typeName: "string", indexAlias: "serviceName" }, + { slot: 3, memberName: "contractPath", typeName: "string" }, + { slot: 4, memberName: "generatedAt", typeName: "string" }, + { slot: 5, memberName: "authority", typeName: "object" }, + { slot: 6, memberName: "namespaces", typeName: "object", isMany: true }, + { slot: 7, memberName: "schemas", typeName: "object", isMany: true }, + { slot: 8, memberName: "witnesses", typeName: "object", isMany: true }, + { slot: 9, memberName: "surfaces", typeName: "object", isMany: true }, + { slot: 10, memberName: "commandBoundaries", typeName: "object", isMany: true }, + { slot: 11, memberName: "styleCapabilities", typeName: "object", isMany: true }, + { slot: 12, memberName: "demotions", typeName: "string", isMany: true }, + ], +}); + +async function main() { + const [command, ...rawArgs] = process.argv.slice(2); + const options = parseArgs(rawArgs); + + if (!command || command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + switch (command) { + case "export": + await exportAdvertisement(options); + return; + case "print": + printJson(buildAdvertisement(options)); + return; + case "schema": + printJson({ + documentType, + schemaId, + documentId, + members: advertisementDefinition.members, + }); + return; + default: + throw new Error(`Unknown command "${command}". Run "node tools/provider-advertisement.mjs help".`); + } +} + +async function exportAdvertisement(options) { + const storePath = resolveOptionPath(options.out ?? options.store ?? defaultStorePath); + await mkdir(dirname(storePath), { recursive: true }); + + const cache = CultCache.builder() + .withDocumentType(advertisementDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await cache.pullAllBackingStores(); + const advertisement = buildAdvertisement(options); + await cache.put(advertisementDefinition, advertisement.providerId, advertisement); + + printJson({ + ok: true, + documentType, + schemaId, + providerId: advertisement.providerId, + out: storePath, + surfaces: advertisement.surfaces.map((surface) => surface.id), + witnesses: advertisement.witnesses.map((witness) => witness.path), + }); +} + +function buildAdvertisement(options) { + return parseAdvertisement({ + providerId: "bifrost", + serviceName: "Bifrost", + contractPath: "docs/verse-service-contract.md", + generatedAt: options["generated-at"] ?? new Date().toISOString(), + authority: { + owner: "Bifrost", + role: "GameCult labor, governance, patron pressure, project work, account membership, and governed-public-crossing provider", + presentationOwner: "Eve/CultUI", + discoveryOwner: "Odin through CultMesh", + stateOwner: "Bifrost typed state with CultCache .cc witnesses or export paths", + runtimeMigration: "not performed by this advertisement", + }, + namespaces: [ + namespace("gamecult.bifrost.service", "service registration, build/version, schema catalog, and command discovery"), + namespace("gamecult.bifrost.governance", "motions, topic threads, comments, votes, approvals, objections, and policy receipts"), + namespace("gamecult.bifrost.work", "projects, work items, claims, review state, completion artifacts, and maintainer acceptance"), + namespace("gamecult.bifrost.economics", "patron pressure, contributor credit, ledger entries, payout proposal batches, and revenue-share inputs"), + namespace("gamecult.bifrost.bridge", "GitHub, Discord, CultNet/CultCache, and future collaboration crossings plus receipts"), + namespace("gamecult.bifrost.surface.product", "member, patron, contributor, and project-facing Eve product surfaces"), + namespace("gamecult.bifrost.surface.operator", "readiness, witness, bridge, schema, migration, and deploy operator surfaces"), + ], + schemas: [ + schema(schemaId, documentType, "this provider advertisement"), + schema("gamecult.eve.surface.v1", "gamecult.eve.surface", "Eve/CultUI surface compositions lowered by Eve runtimes"), + schema("bifrost.governance.topic.v0", "bifrost.governance.topic", "existing CultCache governance topic witness"), + schema("bifrost.governance.topic_comment.v0", "bifrost.governance.topic-comment", "existing CultCache governance comment witness"), + schema("bifrost.agent-transport.update-request.v0", "bifrost.agent-transport.update-request", "existing CultCache/CultNet agent intake witness"), + schema("bifrost.work_item.v0", "bifrost.work-item", "planned Postgres-to-CultCache work item witness"), + schema("bifrost.motion.v0", "bifrost.motion", "planned app-native motion witness"), + schema("bifrost.vote.v0", "bifrost.vote", "planned motion vote witness"), + schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"), + schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "planned governed crossing command witness"), + schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "planned governed crossing result witness"), + schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"), + ], + witnesses: [ + witness(".bifrost/provider-advertisement.cc", schemaId, "current", "read-only provider advertisement exported by this tool"), + witness(".bifrost/governance-threads.cc", "bifrost.governance.topic.v0; bifrost.governance.topic_comment.v0", "current", "governance discussion, approvals, and dispatch promotion topics"), + witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Face update requests and dispatch queue state"), + witness(".bifrost/work-items.cc", "bifrost.work_item.v0", "planned-export", "work items exported from the alpha transactional store"), + witness(".bifrost/motions.cc", "bifrost.motion.v0; bifrost.vote.v0", "planned-export", "app-native motions and votes exported from the alpha transactional store"), + witness(".bifrost/ledger.cc", "bifrost.ledger_entry.v0", "planned-export", "patron and contributor ledger entries exported from the alpha transactional store"), + witness(".bifrost/member-capabilities.cc", "bifrost.member_capability_snapshot.v0", "planned-export", "membership and account capability snapshots consumed by Bifrost"), + witness(".bifrost/bridge-receipts.cc", "bifrost.bridge_action.v0; bifrost.bridge_receipt.v0", "planned-export", "governed public crossing actions and receipts"), + witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "product and operator Eve/CultUI surface publications"), + ], + surfaces: [ + surface("bifrost.account", "Account Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "membership status", + "Heimdall-linked account projection", + "grant consumption", + "audit trail lowerings", + ]), + surface("bifrost.patron", "Patron Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "patron standing", + "priority pressure", + "pledge/reward influence", + "receipts", + ]), + surface("bifrost.project", "Project Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "project membership", + "repository links", + "maintainer authority", + "work boards", + ]), + surface("bifrost.work", "Work Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "work items", + "claims", + "review", + "blockers", + "completion artifacts", + ]), + surface("bifrost.motion", "Motion Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "motions", + "topic threads", + "votes", + "approvals", + "objections", + ]), + surface("bifrost.operator", "Bifrost Operator Verse", "gamecult.bifrost.surface.operator", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", [ + "readiness", + "witness freshness", + "bridge queues", + "schema catalog", + "migration drift", + ]), + ], + commandBoundaries: [ + boundary("work", "Bifrost", [ + "claim work", + "submit completion", + "record blockers", + "maintainer accept/reject", + ], [ + "does not execute payout", + "does not let GitHub issue state override Bifrost work authority", + ]), + boundary("motion", "Bifrost", [ + "open motion", + "comment", + "vote", + "approve", + "object", + "promote to dispatch", + ], [ + "Discord mirrors cannot become canonical governance without a committed Bifrost document", + ]), + boundary("patron", "Bifrost", [ + "record patron pressure", + "surface reward influence", + "emit standing receipts", + ], [ + "does not charge cards", + "does not execute external payout rails", + ]), + boundary("project", "Bifrost plus project maintainers", [ + "link repositories", + "publish work boards", + "surface maintainer authority", + ], [ + "does not seize repo Face cognition or project-local ownership", + ]), + boundary("account", "Bifrost consumes Heimdall claims", [ + "display membership status", + "consume linked-account grants", + "record Bifrost audit trails", + ], [ + "does not mint identity grants", + "does not own OAuth provider truth", + ]), + boundary("bridge", "Bifrost", [ + "prepare governed public crossings", + "execute approved handoffs", + "record receipts", + ], [ + "does not treat local protocol JSON as work authority", + "does not touch secrets in this advertisement", + ]), + ], + styleCapabilities: [ + style("density", ["compact TUI grids", "operator scan tables", "member-facing board/list/detail lowerings"]), + style("visualEncoding", ["status badges", "priority swatches", "role/tier labels", "receipt/audit timelines"]), + style("interaction", ["tabs", "filters", "segmented modes", "claim/review/vote command affordances", "read-only witness inspection"]), + style("loweringTargets", ["Eve native", "browser/Razor transitional lowering", "compact TUI", "future room/overlay projections"]), + style("tone", ["quiet operational product", "governance/labor clarity", "no crypto/DAO styling", "no separate marketing dashboard truth"]), + ], + demotions: [ + "Razor Pages are browser lowerings, not the canonical presentation owner.", + "HTTP health/readiness JSON is a probe, not service truth.", + "Discord messages are mirrors and input surfaces until Bifrost commits typed state.", + "Local dispatch JSON is evidence for receipts, not command authority.", + "This advertisement is read-only discovery metadata and does not migrate runtime state.", + ], + }); +} + +function namespace(id, purpose) { + return { id, purpose }; +} + +function schema(id, type, purpose) { + return { id, type, purpose }; +} + +function witness(path, schemaIds, status, purpose) { + return { path, schemaIds, status, purpose }; +} + +function surface(id, name, namespace, schemaId, witnessPath, capabilities) { + return { id, name, namespace, schemaId, witnessPath, capabilities }; +} + +function boundary(area, owner, commands, forbiddenAuthority) { + return { area, owner, commands, forbiddenAuthority }; +} + +function style(area, capabilities) { + return { area, capabilities }; +} + +function parseAdvertisement(input) { + if (!input || typeof input !== "object") { + throw new Error("Provider advertisement must be an object."); + } + + const advertisement = { + providerId: requireString(input.providerId, "providerId"), + serviceName: requireString(input.serviceName, "serviceName"), + contractPath: requireString(input.contractPath, "contractPath"), + generatedAt: requireString(input.generatedAt, "generatedAt"), + authority: requireObject(input.authority, "authority"), + namespaces: requireObjectArray(input.namespaces, "namespaces"), + schemas: requireObjectArray(input.schemas, "schemas"), + witnesses: requireObjectArray(input.witnesses, "witnesses"), + surfaces: requireObjectArray(input.surfaces, "surfaces"), + commandBoundaries: requireObjectArray(input.commandBoundaries, "commandBoundaries"), + styleCapabilities: requireObjectArray(input.styleCapabilities, "styleCapabilities"), + demotions: requireStringArray(input.demotions, "demotions"), + }; + + const requiredSurfaces = new Set(["bifrost.work", "bifrost.motion", "bifrost.patron", "bifrost.project", "bifrost.account"]); + const surfaceIds = new Set(advertisement.surfaces.map((surface) => surface.id)); + for (const id of requiredSurfaces) { + if (!surfaceIds.has(id)) { + throw new Error(`Provider advertisement must name ${id}.`); + } + } + + return advertisement; +} + +function parseArgs(args) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (!token.startsWith("--")) { + throw new Error(`Unexpected argument "${token}".`); + } + + const key = token.slice(2); + const next = args[index + 1]; + if (!next || next.startsWith("--")) { + options[key] = "true"; + continue; + } + + options[key] = next; + index += 1; + } + + return options; +} + +function requireString(value, field) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Provider advertisement field "${field}" must be a non-empty string.`); + } + + return value.trim(); +} + +function requireObject(value, field) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Provider advertisement field "${field}" must be an object.`); + } + + return value; +} + +function requireObjectArray(value, field) { + if (!Array.isArray(value) || value.some((item) => !item || typeof item !== "object" || Array.isArray(item))) { + throw new Error(`Provider advertisement field "${field}" must be an array of objects.`); + } + + return value; +} + +function requireStringArray(value, field) { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) { + throw new Error(`Provider advertisement field "${field}" must be an array of non-empty strings.`); + } + + return value.map((item) => item.trim()); +} + +function resolveOptionPath(path) { + return resolve(process.cwd(), path); +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function printHelp() { + process.stdout.write(`Bifrost Eve provider advertisement + +Commands: + export Write the Bifrost gamecult.eve.provider_advertisement.v1 document to a CultCache .cc witness + print Print the advertisement as protocol-debug JSON without writing state + schema Print document type metadata + +Options: + --out Override export path; defaults to .bifrost/provider-advertisement.cc + --generated-at Pin generatedAt for deterministic fixture checks + +Examples: + node tools/provider-advertisement.mjs print + node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc +`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +});