Gate organizational Persona feedback

This commit is contained in:
Meta Vens 2026-07-18 20:32:10 +01:00
commit c65cbfa527
7 changed files with 309 additions and 0 deletions

View file

@ -192,6 +192,26 @@ The command is a dry-run shape probe for public surfaces that are real enough to
## Discord Native Interface Target
### Organizational Persona Feedback Ingress
VoidBot remains the Discord observer and mention scheduler. A Face registry entry may opt into a remote `remotePersonaFeedbackTarget`; only then does VoidBot insert an immutable typed `voidbot.discord.persona_feedback_event.v0` observation into the dedicated `BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH`. This is an observation inbox, never an actuator queue. Insert collisions must contain exactly equal content. Its canonical path and physical file identity must differ from the outbound Discord-command provider store and all Bifrost-private state. Local Persona mention handling is committed first; observation export runs independently with three bounded idempotent attempts and cannot suppress the local response path.
Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.
`tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path.
The public trust-anchor artifact is a Rust-compatible six-element MessagePack tuple in `HostIdentityTrustAnchorEntry` declaration order: schema version, identity id, 32-byte public key, assurance, identity creation time, and source-record digest. It contains no private material.
The signed `bifrost.persona_feedback.delivery.v0` document matches Epiphany's `BifrostPersonaFeedbackAdmission`: its packet digest is `sha256-` plus SHA-256 of the Rust field-order MessagePack packet tuple, and Ed25519 signs the Epiphany host-identity domain message for purpose `bifrost.persona-feedback.delivery.v0` over the exact admission tuple. The observation provenance is `sourceObserverId=voidbot`; the admission provider is separately and explicitly `provider=bifrost`. Authority is `feedback_only`, and the canonical Epiphany repository target is `GameCult/Epiphany`.
Each binding selects a dedicated provider-owned CultCache store containing only signed delivery documents. The canonical Epiphany path is `/srv/bifrost/persona-feedback/deliveries.cc`; Windows development defaults to `.bifrost/epiphany-persona-feedback.cc`. Observation inbox, Bifrost-private provider state, and delivery store must have distinct canonical paths and distinct existing file identities. Publication is crash-replayable, not cross-store transactional: the signed delivery is inserted idempotently first, then Bifrost records its private delivery copy and immutable receipt. A crash between those writes leaves the source observation unchanged and eligible for replay. Epiphany may consume the delivery store read-only without gaining access to bindings, actor links, receipts, or signing state. CultNet snapshot export remains available.
The Bifrost service runs `tools/persona-feedback.mjs serve` with separate `--observation-store`, private `--store`, private-key, and public `--delivery-store` paths. This bounded resident pump scans immutable source events in observation order, derives unprocessed state solely from absence of an immutable Bifrost receipt, processes each through the same exact binding/signing primitive, and sleeps for `--interval-ms` (default five seconds). Failed events remain eligible for retry; admitted events are never rewritten, deleted, or re-granted. VoidBot does not invoke this mode and requires no read access to Bifrost's private provider state. `--once true` exists for service probes and fixtures.
The deploy contract is one ordinary Bifrost sidecar process with Node.js and the built CultLib CultCache/CultMesh/CultNet runtimes. Provision three separately owned paths: a VoidBot-writable/Bifrost-readable observation inbox, a Bifrost-only private provider store, and the Bifrost-writable/Epiphany-readable delivery store at `/srv/bifrost/persona-feedback/deliveries.cc`. Enroll the Bifrost-only seed once, export its six-field public trust-anchor tuple for Epiphany, create exact target bindings (including visibility, data classification, and delivery path), then start `serve`. The service needs no Discord credential and no Eve surface; it transforms typed organizational feedback observations into authenticated cognition pressure.
`tools/persona-feedback.mjs status` is the non-actuating container readiness probe. It loads the runtime, checks store path and existing-file identity separation, verifies the service-owned key identity, reads bindings without modifying them, and checks each binding's delivery parent/write posture. It emits `bifrost.persona_feedback.readiness.v0` with provider identity, binding count, readiness status, reasons, and `privateStateExposed=false`. It never processes an observation and is not deployment admission.
Discord should become a native Bifrost interface for Bifrost-scoped work, not just an output pipe.
The intended shape:

View file

@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash, createPublicKey, verify } from "node:crypto";
import { createRequire } from "node:module";
import { mkdtemp, stat, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs";
const root=resolve(import.meta.dirname,".."), cult=resolve(root,"..","CultLib");
const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json"));
const mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json"));
const {defineDocumentType}=cr("cultcache-ts"), {CultMesh}=mr("cultmesh-ts"), defs=buildFeedbackDefinitions(defineDocumentType);
const nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),{decode,encode}=nr("@msgpack/msgpack");
test("Bifrost admits bound feedback as pressure only and exports CultNet",async()=>{
const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")), store=resolve(dir,"provider.cc"), observationStore=resolve(dir,"observations.cc"), deliveryStore=resolve(dir,"delivery.cc"), unusedDeliveryDefault=resolve(dir,"unused-delivery.cc"), out=resolve(dir,"feedback.msgpack"), key=resolve(dir,"identity.seed"), anchor=resolve(dir,"anchor.msgpack");
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",deliveryStore);
await putEvent(observationStore,event("one","human-one"));
const sourceBefore=await readEvent(observationStore,"one");
const served=cli("serve",store,"--observation-store",observationStore,"--once","true","--private-key",key,"--delivery-store",unusedDeliveryDefault,"--interval-ms","1");
assert.equal(served.scanned,1);assert.equal(served.results[0].ok,true);
assert.deepEqual(await readEvent(observationStore,"one"),sourceBefore);
const n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});await n.cache?.pullAllBackingStores?.();
const first={delivery:(await n.get(defs.delivery,"one"))?.value??await n.get(defs.delivery,"one"),receipt:(await n.get(defs.receipt,"one"))?.value??await n.get(defs.receipt,"one")};
assert.equal(first.receipt.classification,"unlinked_social_feedback");
assert.equal(first.delivery.authority,"feedback_only");
assert.equal(first.delivery.packet.privateStateIncluded,false);
assert.equal(first.delivery.packet.sourceVisibility,"organization");
assert.equal(first.delivery.packet.dataClassification,"organization_feedback");
assert.match(first.delivery.packetSha256,/^sha256-[a-f0-9]{64}$/);
assert.equal(Object.keys(first.delivery.providerSignature).length,64);
const signed=(await n.get(defs.delivery,"one"))?.value??await n.get(defs.delivery,"one"), trust=decode(await readFile(anchor));
assert.equal(signed.packetSha256,`sha256-${createHash("sha256").update(encode(packetTuple(signed.packet))).digest("hex")}`);
assert.equal(signed.sourceObserverId,"voidbot");assert.equal(signed.provider,"bifrost");
const payload=encode([signed.schemaVersion,signed.admissionId,packetTuple(signed.packet),signed.packetSha256,signed.sourceObserverId,signed.sourceObserverRuntimeId,signed.provider,signed.bifrostAdmissionReceiptId,signed.authority,signed.providerIdentityId]);
const purpose=Buffer.from("bifrost.persona-feedback.delivery.v0"),domain=Buffer.from("epiphany.host-incarnation.signature.v0\0"),message=Buffer.concat([domain,u64(purpose.length),purpose,u64(payload.length),payload]);
assert.equal(trust.length,6); assert.equal(trust[0],"epiphany.host_identity_trust_anchor.v0"); assert.equal(trust[1],signed.providerIdentityId);
const publicKey=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(trust[2])]),format:"der",type:"spki"});
assert.equal(verify(null,message,publicKey,Buffer.from(signed.providerSignature)),true);
assert.equal(first.receipt.grantsWorkAuthority,false);
cli("link-actor",store,"--guild-id","g","--discord-author-id","human-two","--bifrost-actor-id","member:42","--heimdall-actor-ref","heimdall:42");
await putEvent(observationStore,event("two","human-two"));
assert.equal(cli("process",store,"--observation-store",observationStore,"--event-id","two","--private-key",key,"--delivery-store",deliveryStore).receipt.classification,"linked_governance_feedback");
const replay=cli("process",store,"--observation-store",observationStore,"--event-id","two","--private-key",key,"--delivery-store",deliveryStore);
assert.equal(replay.receiptId,"two");
const exported=cli("export",store,"--out",out);
assert.ok(exported.documentCount>=4); assert.ok((await stat(out)).size>0);
const deliveryCache=cr("cultcache-ts").CultCache.builder().withDocumentType(defs.delivery).withGenericStore(new (cr("cultcache-ts").SingleFileMessagePackBackingStore)(deliveryStore)).build();await deliveryCache.pullAllBackingStores();assert.equal(deliveryCache.getAll(defs.delivery).length,2);
const beforeStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));
const readiness=cli("status",store,"--observation-store",observationStore,"--private-key",key);
assert.equal(readiness.schemaVersion,"bifrost.persona_feedback.readiness.v0");assert.equal(readiness.status,"ready");assert.equal(readiness.bindingCount,1);assert.equal(readiness.privateStateExposed,false);
const afterStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));assert.deepEqual(afterStatus,beforeStatus);
const Store=cr("cultcache-ts").SingleFileMessagePackBackingStore;
assert.deepEqual([...new Set((await new Store(observationStore).pullAll()).map(entry=>entry.type))],["voidbot.discord.persona_feedback_event"]);
assert.equal((await new Store(store).pullAll()).some(entry=>entry.type==="voidbot.discord.persona_feedback_event"),false);
assert.deepEqual([...new Set((await new Store(deliveryStore).pullAll()).map(entry=>entry.type))],["bifrost.persona_feedback.delivery"]);
await assert.rejects(stat(unusedDeliveryDefault));
});
test("Bifrost rejects an event without exact target binding",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observationStore=resolve(dir,"observations.cc");await putEvent(observationStore,event("wrong","human","other-runtime"));const r=raw("process",store,"--observation-store",observationStore,"--event-id","wrong");assert.notEqual(r.status,0);assert.match(r.stderr,/No exact Bifrost target binding/);assert.equal((await readEvent(observationStore,"wrong")).status,"pending");});
test("Bifrost rejects aliased observation, private, and delivery stores",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc");await putEvent(observations,event("alias","human"));const r=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",store);assert.notEqual(r.status,0);assert.match(r.stderr,/distinct canonical paths/);});
test("Bifrost rejects mismatched classification and malformed observations",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc");const mismatch=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","private","--data-classification","public_feedback","--delivery-store",delivery);assert.notEqual(mismatch.status,0);assert.match(mismatch.stderr,/exact admitted pair/);for(const malformed of [{observedAt:"yesterday"},{addressingMode:"ambient"},{authorityClass:"work"},{status:"admitted"},{payloadHash:"not-a-digest"}])await assert.rejects(putEvent(observations,{...event(`bad-${Object.keys(malformed)[0]}`,"human"),...malformed}));});
function event(id,author,targetRuntimeId="epiphany-yggdrasil"){const content=`feedback ${id}`;return {schemaName:"voidbot.discord.persona_feedback_event",schemaVersion:"voidbot.discord.persona_feedback_event.v0",eventId:id,observedAt:new Date().toISOString(),guildId:"g",channelId:"c",messageId:`m-${id}`,authorId:author,authorName:author,addressingMode:"role",targetPersonaId:"epiphany",targetRepoName:"GameCult/Epiphany",targetRuntimeId,content,payloadHash:createHash("sha256").update(content).digest("hex"),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"feedback_only",status:"pending"};}
async function putEvent(store,value){const n=await CultMesh.createNode(store,{documents:[defs.event]});await n.put(defs.event,value.eventId,value);await n.flush?.();}
async function readEvent(store,id){const n=await CultMesh.createNode(store,{documents:[defs.event]});await n.cache?.pullAllBackingStores?.();return (await n.get(defs.event,id))?.value??await n.get(defs.event,id);}
function raw(command,store,...args){return spawnSync(process.execPath,[resolve(root,"tools","persona-feedback.mjs"),command,"--store",store,...args],{cwd:root,encoding:"utf8"});}
function cli(command,store,...args){const r=raw(command,store,...args);assert.equal(r.status,0,r.stderr||r.stdout);return JSON.parse(r.stdout);}
function cliNoStore(command,...args){const r=spawnSync(process.execPath,[resolve(root,"tools","persona-feedback.mjs"),command,...args],{cwd:root,encoding:"utf8"});assert.equal(r.status,0,r.stderr||r.stdout);return JSON.parse(r.stdout);}
function packetTuple(p){return [p.feedbackId,p.sourceEventId,p.sourceActorId,p.actorClassification,p.actorLinkRefs,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.targetRepository,p.targetPersonaId,p.sourceRoomId,p.feedbackText,p.contentSha256,p.sourceDiscussionRefs,p.sourceVisibility,p.dataClassification,p.privateStateIncluded];}
function u64(v){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(v));return b;}

View file

@ -0,0 +1,35 @@
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as ed25519Sign } from "node:crypto";
import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const ID_DOMAIN=Buffer.from("epiphany.host-incarnation.identity.v0\0","utf8");
const SIGNATURE_DOMAIN=Buffer.from("epiphany.host-incarnation.signature.v0\0","utf8");
export const FEEDBACK_SIGNING_PURPOSE="bifrost.persona-feedback.delivery.v0";
export async function enrollBifrostFeedbackIdentity(privateKeyPath){
await mkdir(dirname(privateKeyPath),{recursive:true});
const {privateKey}=generateKeyPairSync("ed25519"), jwk=privateKey.export({format:"jwk"});
const seed=Buffer.from(jwk.d,"base64url");
const handle=await open(privateKeyPath,"wx",0o600);
try{await handle.writeFile(seed);await handle.sync();}finally{await handle.close();}
await chmod(privateKeyPath,0o600);
return loadBifrostFeedbackIdentity(privateKeyPath);
}
export async function loadBifrostFeedbackIdentity(privateKeyPath){
const info=await stat(privateKeyPath);
if(!info.isFile())throw new Error("Bifrost feedback identity must be a regular file.");
if(process.platform!=="win32"&&(info.mode&0o077)!==0)throw new Error("Bifrost feedback identity private key must have mode 0600.");
if(process.platform!=="win32"&&typeof process.geteuid==="function"&&info.uid!==process.geteuid())throw new Error("Bifrost feedback identity private key must be owned by the service user.");
const seed=await readFile(privateKeyPath);if(seed.length!==32)throw new Error("Bifrost feedback identity seed must be exactly 32 bytes.");
const key=createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),seed]),format:"der",type:"pkcs8"});
const publicKey=createPublicKey(key).export({format:"der",type:"spki"}).subarray(-32);
const identityId=createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex");
return {identityId,publicKey,key};
}
export function signFeedbackAdmission(identity,payload){
const purpose=Buffer.from(FEEDBACK_SIGNING_PURPOSE,"utf8"), message=Buffer.concat([SIGNATURE_DOMAIN,u64(purpose.length),purpose,u64(payload.length),payload]);
return ed25519Sign(null,message,identity.key);
}
export function trustAnchor(identity,createdAt){return {schemaVersion:"epiphany.host_identity_trust_anchor.v0",identityId:identity.identityId,publicKey:new Uint8Array(identity.publicKey),assurance:"os_service_file_bound",identityCreatedAt:createdAt,sourceIdentityRecordSha256:`sha256-${createHash("sha256").update(identity.publicKey).digest("hex")}`};}
export function defaultFeedbackIdentityPath(){return process.env.BIFROST_PERSONA_FEEDBACK_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","persona-feedback-ed25519.seed"):"/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed");}
function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;}

View file

@ -0,0 +1,41 @@
export const feedbackEventType = "voidbot.discord.persona_feedback_event";
export const feedbackEventSchema = "voidbot.discord.persona_feedback_event.v0";
export const feedbackBindingType = "bifrost.persona_feedback.target_binding";
export const feedbackBindingSchema = "bifrost.persona_feedback.target_binding.v0";
export const actorLinkType = "bifrost.discord.actor_link";
export const actorLinkSchema = "bifrost.discord.actor_link.v0";
export const feedbackReceiptType = "bifrost.persona_feedback.admission_receipt";
export const feedbackReceiptSchema = "bifrost.persona_feedback.admission_receipt.v0";
export const feedbackDeliveryType = "bifrost.persona_feedback.delivery";
export const feedbackDeliverySchema = "bifrost.persona_feedback.delivery.v0";
// Kept as explicit constructors because CultCache document metadata is part of the wire contract.
export function buildFeedbackDefinitions(defineDocumentType) {
const make = (type, schemaId, name, parse) => defineDocumentType({ type, schemaName: type, schemaId, schemaVersion: schemaId, contentHash: schemaId, global: false, name, schema: { parse } });
return {
event: make(feedbackEventType, feedbackEventSchema, "eventId", parseEvent),
binding: make(feedbackBindingType, feedbackBindingSchema, "bindingId", parseBinding),
actorLink: make(actorLinkType, actorLinkSchema, "linkId", parseActorLink),
receipt: make(feedbackReceiptType, feedbackReceiptSchema, "receiptId", parseReceipt),
delivery: make(feedbackDeliveryType, feedbackDeliverySchema, "admissionId", parseDelivery),
};
}
export function parseEvent(value) {
const v = record(value, "feedback event");
exact(v.schemaVersion, feedbackEventSchema, "event schema");
for (const key of ["eventId","guildId","channelId","messageId","authorId","addressingMode","targetPersonaId","targetRepoName","targetRuntimeId","content","payloadHash","producerId","producerRuntimeId"]) text(v[key], key);
exact(v.producerId, "voidbot", "producer"); exact(v.authorityClass, "feedback_only", "authorityClass");
exact(v.status, "pending", "status");
if(!["role","text","reply","broadcast"].includes(v.addressingMode))throw new Error("addressingMode is invalid.");
if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v.observedAt)||Number.isNaN(Date.parse(v.observedAt)))throw new Error("observedAt must be RFC3339.");
if (!/^[a-f0-9]{64}$/.test(v.payloadHash)) throw new Error("payloadHash must be sha256 hex.");
return v;
}
export function parseBinding(value) { const v = record(value,"target binding"); exact(v.schemaVersion,feedbackBindingSchema,"binding schema"); for (const k of ["bindingId","guildId","channelId","targetPersonaId","targetRepoName","targetRuntimeId","producerId","producerRuntimeId","sourceVisibility","dataClassification","deliveryStorePath"]) text(v[k],k); const expected={public:"public_feedback",organization:"organization_feedback",private:"private_feedback"}[v.sourceVisibility];if(!expected||v.dataClassification!==expected)throw new Error("sourceVisibility and dataClassification must be the exact admitted pair."); return v; }
export function parseActorLink(value) { const v=record(value,"actor link"); exact(v.schemaVersion,actorLinkSchema,"actor link schema"); for(const k of ["linkId","guildId","discordAuthorId","bifrostActorId","heimdallActorRef"]) text(v[k],k); return v; }
export function parseReceipt(value) { const v=record(value,"receipt"); exact(v.schemaVersion,feedbackReceiptSchema,"receipt schema"); for(const k of ["receiptId","eventId","status","classification","payloadHash","bindingId","recordedAt"]) text(v[k],k); return v; }
export function parseDelivery(value) { const v=record(value,"delivery"); exact(v.schemaVersion,feedbackDeliverySchema,"delivery schema"); for(const k of ["admissionId","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId"]) text(v[k],k); exact(v.sourceObserverId,"voidbot","sourceObserverId"); exact(v.provider,"bifrost","provider"); exact(v.authority,"feedback_only","authority"); if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes."); const p=record(v.packet,"packet"); for(const k of ["feedbackId","sourceEventId","sourceActorId","actorClassification","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","targetRepository","targetPersonaId","sourceRoomId","feedbackText","contentSha256","sourceVisibility","dataClassification"])text(p[k],k);if(!Array.isArray(p.sourceDiscussionRefs)||p.sourceDiscussionRefs.length===0)throw new Error("sourceDiscussionRefs are required.");if(p.privateStateIncluded!==false)throw new Error("privateStateIncluded describes embedded private machine state and must be false.");return v; }
function record(value,label){if(!value||typeof value!=="object"||Array.isArray(value))throw new Error(`${label} must be an object.`);return value;}
function text(value,label){if(typeof value!=="string"||!value.trim())throw new Error(`${label} is required.`);return value;}
function exact(value,expected,label){if(value!==expected)throw new Error(`${label} must be ${expected}.`);}

View file

@ -0,0 +1,38 @@
import dgram from "node:dgram";
import { createRequire } from "node:module";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const projects = resolve(root, "..");
const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projects, "CultLib"));
const require = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"));
const { encodeCultNetMessageForWire, encodeRudpPacket } = require("cultnet-ts");
const { encode } = require("@msgpack/msgpack");
const protocol = "cultnet.transport.rudp.v0";
export async function publishPersonaFeedbackHealth(input) {
if (!input.endpoint) throw new Error("Bifrost Persona-feedback Idunn health endpoint is required.");
const endpoint = parseEndpoint(input.endpoint);
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
try {
await new Promise((ok, fail) => { socket.once("error", fail); socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { socket.off("error", fail); ok(); }); });
await send(socket, endpoint, packet("connect", 1, new Uint8Array(), "control"));
await delay(300);
const observedAt = input.observedAt || new Date().toISOString();
const payload = encode([input.daemonId, input.state, input.detail, observedAt, input.healthContract, "daemon-published", protocol]);
const message = {schemaVersion:"cultnet.document_put_raw.v0",messageId:`bifrost-feedback-health:${observedAt.replace(/[:.]/g,"-")}`,document:{schemaId:"idunn.daemon_health",recordKey:input.daemonId,storedAt:observedAt,payloadEncoding:"messagepack",payload,sourceRuntimeId:"bifrost-persona-feedback",sourceRole:"daemon-health-publisher",tags:[protocol]}};
await send(socket, endpoint, packet("data", 2, encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), "schema"));
await delay(1000);
} finally { socket.close(); }
}
export function parseEndpoint(value) {
const text=String(value||"").trim(), ipv6=text.match(/^\[([^\]]+)\]:(\d+)$/);
if(ipv6)return {host:ipv6[1],port:port(ipv6[2])};
const split=text.lastIndexOf(":");if(split<=0)throw new Error(`Idunn RUDP endpoint must be host:port, got "${value}".`);
return {host:text.slice(0,split),port:port(text.slice(split+1))};
}
function port(value){const result=Number(value);if(!Number.isInteger(result)||result<=0||result>65535)throw new Error(`Idunn RUDP endpoint port is invalid: ${value}`);return result;}
function packet(packetType,sequence,payload,channelId){return {packetType,connectionId:0x1d0d0001,sequence,ack:0,ackMask:0,channelId,reliable:true,ordered:true,sequenced:false,payload};}
async function send(socket,endpoint,value){const wire=encodeRudpPacket(value);await new Promise((ok,fail)=>socket.send(wire,endpoint.port,endpoint.host,error=>error?fail(error):ok()));}
function delay(ms){return new Promise(ok=>setTimeout(ok,ms));}

View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { constants } from "node:fs";
import { access, mkdir, realpath, rm, stat, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedbackDeliverySchema, feedbackReceiptSchema } from "./persona-feedback-documents.mjs";
import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs";
import { publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs";
const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib"));
const [command,...rawArgs]=process.argv.slice(2),options=parseArgs(rawArgs);
const privateStorePath=resolve(options.store||(process.platform==="win32"?resolve(root,".bifrost","provider-store.cc"):"/var/lib/gamecult/bifrost/persona-feedback/provider.cc"));
const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-feedback-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc"));
const deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.cc"));
const runtime=loadRuntime(),defs=buildFeedbackDefinitions(runtime.defineDocumentType);
try{await main();}catch(error){console.error(error instanceof Error?error.message:String(error));process.exitCode=1;}
async function main(){
if(command==="bind-target")return bindTarget();
if(command==="link-actor")return linkActor();
if(command==="process")return processEvent(requireOption("event-id"));
if(command==="serve")return serve();
if(command==="export")return exportSnapshot();
if(command==="enroll-identity")return enrollIdentity();
if(command==="status")return status();
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|status|export|enroll-identity");
}
async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});}
async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});}
async function bindTarget(){const n=await privateNode(),binding={schemaName:"bifrost.persona_feedback.target_binding",schemaVersion:feedbackBindingSchema,bindingId:bindingId(options),guildId:requireOption("guild-id"),channelId:requireOption("channel-id"),targetPersonaId:requireOption("persona-id"),targetRepoName:requireOption("repo"),targetRuntimeId:requireOption("runtime-id"),producerId:options["producer-id"]||"voidbot",producerRuntimeId:requireOption("producer-runtime-id"),sourceVisibility:requireOption("source-visibility"),dataClassification:requireOption("data-classification"),deliveryStorePath:resolve(options["delivery-store"]||deliveryStorePath),createdAt:new Date().toISOString()};await assertStoreSeparation(binding.deliveryStorePath);await n.put(defs.binding,binding.bindingId,binding);await n.flush?.();print(binding);}
async function linkActor(){const n=await privateNode(),link={schemaName:"bifrost.discord.actor_link",schemaVersion:actorLinkSchema,linkId:`${requireOption("guild-id")}:${requireOption("discord-author-id")}`,guildId:requireOption("guild-id"),discordAuthorId:requireOption("discord-author-id"),bifrostActorId:requireOption("bifrost-actor-id"),heimdallActorRef:requireOption("heimdall-actor-ref"),linkedAt:new Date().toISOString()};await n.put(defs.actorLink,link.linkId,link);await n.flush?.();print(link);}
async function processEvent(id,emit=true){
await assertStoreSeparation(deliveryStorePath);
const observations=await observationNode(),privateNodeValue=await privateNode();await observations.cache?.pullAllBackingStores?.();await privateNodeValue.cache?.pullAllBackingStores?.();
const event=unwrap(await observations.get(defs.event,id));if(!event)throw new Error(`Unknown feedback event ${id}.`);
const existing=unwrap(await privateNodeValue.get(defs.receipt,id));if(existing){await republishExisting(privateNodeValue,id);if(emit)print(existing);return;}
if(shaText(event.content)!==event.payloadHash)throw new Error("Feedback payload hash mismatch.");
const binding=unwrap(await privateNodeValue.get(defs.binding,bindingId({"guild-id":event.guildId,"channel-id":event.channelId,"persona-id":event.targetPersonaId,repo:event.targetRepoName,"runtime-id":event.targetRuntimeId,"producer-id":event.producerId,"producer-runtime-id":event.producerRuntimeId})));
if(!binding)throw new Error("No exact Bifrost target binding admits this feedback event.");
const link=unwrap(await privateNodeValue.get(defs.actorLink,`${event.guildId}:${event.authorId}`)),classification=link?"linked_governance_feedback":"unlinked_social_feedback",sourceActorId=link?.bifrostActorId||`discord:${event.guildId}:${event.authorId}`;
const packet={feedbackId:id,sourceEventId:id,sourceActorId,actorClassification:classification,actorLinkRefs:link?[link.heimdallActorRef]:[],discordGuildId:event.guildId,discordChannelId:event.channelId,discordMessageId:event.messageId,targetRuntimeId:event.targetRuntimeId,targetRepository:event.targetRepoName,targetPersonaId:event.targetPersonaId,sourceRoomId:`discord:${event.guildId}:${event.channelId}`,feedbackText:event.content,contentSha256:`sha256-${event.payloadHash}`,sourceDiscussionRefs:[`discord:${event.guildId}:${event.channelId}:${event.messageId}`],sourceVisibility:binding.sourceVisibility,dataClassification:binding.dataClassification,privateStateIncluded:false};
const identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));
const admission={schemaName:"bifrost.persona_feedback.delivery",schemaVersion:feedbackDeliverySchema,admissionId:id,packet,packetSha256:`sha256-${shaBytes(runtime.encode(packetTuple(packet)))}`,sourceObserverId:"voidbot",sourceObserverRuntimeId:event.producerRuntimeId,provider:"bifrost",bifrostAdmissionReceiptId:id,authority:"feedback_only",providerIdentityId:identity.identityId,providerSignature:new Uint8Array()};
admission.providerSignature=new Uint8Array(signFeedbackAdmission(identity,runtime.encode(admissionSigningTuple(admission))));
const receipt={schemaName:"bifrost.persona_feedback.admission_receipt",schemaVersion:feedbackReceiptSchema,receiptId:id,eventId:id,status:"admitted",classification,payloadHash:event.payloadHash,bindingId:binding.bindingId,deliveryId:id,recordedAt:new Date().toISOString(),immutable:true,grantsWorkAuthority:false,grantsAdoptionAuthority:false};
await publishAdmission(admission,binding.deliveryStorePath);await privateNodeValue.put(defs.delivery,id,admission);await privateNodeValue.put(defs.receipt,id,receipt);await privateNodeValue.flush?.();if(emit)print({receipt,delivery:admission,deliveryStore:binding.deliveryStorePath});
}
async function republishExisting(n,id){const admission=unwrap(await n.get(defs.delivery,id));if(!admission)throw new Error(`Feedback receipt ${id} has no signed admission.`);const binding=unwrap(await n.get(defs.binding,bindingIdFromAdmission(admission)));if(!binding)throw new Error(`Feedback admission ${id} has no target binding.`);await publishAdmission(admission,binding.deliveryStorePath);}
async function publishAdmission(admission,path){await assertStoreSeparation(path);await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();const current=cache.get(defs.delivery,admission.admissionId);if(current&&stable(current)!==stable(admission))throw new Error(`Signed admission ${admission.admissionId} already exists with different immutable content.`);if(!current)await cache.put(defs.delivery,admission.admissionId,admission);}
async function serve(){const interval=positiveInteger(options["interval-ms"]||"5000","interval-ms"),once=options.once==="true",healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint&&!once)throw new Error("Persona-feedback serve requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH; no localhost default is assumed.");do{const observations=await observationNode(),privateNodeValue=await privateNode();await observations.cache?.pullAllBackingStores?.();await privateNodeValue.cache?.pullAllBackingStores?.();const admitted=new Set(privateNodeValue.cache.getAll(defs.receipt).map(value=>value.eventId));const pending=observations.cache.getAll(defs.event).filter(event=>!admitted.has(event.eventId)).sort((a,b)=>String(a.observedAt).localeCompare(String(b.observedAt)));const results=[];for(const event of pending){try{await processEvent(event.eventId,false);results.push({eventId:event.eventId,ok:true,error:""});}catch(error){results.push({eventId:event.eventId,ok:false,error:error instanceof Error?error.message:String(error)});}}const readinessState=await readiness();if(healthEndpoint)await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:readinessState.status==="ready"?"healthy":"degraded",detail:readinessState.reasons.length?readinessState.reasons.join("; "):"Bifrost Persona-feedback provider is ready."});if(once){print({ok:results.every(value=>value.ok)&&readinessState.status==="ready",scanned:pending.length,results,readiness:readinessState});return;}await new Promise(done=>setTimeout(done,interval));}while(true);}
async function enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),anchor=trustAnchor(identity,new Date().toISOString()),out=resolve(requireOption("trust-anchor-out"));await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId});}
async function exportSnapshot(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const out=resolve(requireOption("out")),scratch=`${out}.scratch.cc`,cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withDocumentType(defs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(scratch)).build();for(const value of n.cache.getAll(defs.delivery))await cache.put(defs.delivery,value.admissionId,value);for(const value of n.cache.getAll(defs.receipt))await cache.put(defs.receipt,value.receiptId,value);const registry=new runtime.CultNetDocumentRegistry([runtime.defineCultNetDocumentBinding({definition:defs.delivery,payloadSchemaVersion:feedbackDeliverySchema}),runtime.defineCultNetDocumentBinding({definition:defs.receipt,payloadSchemaVersion:feedbackReceiptSchema})]),message=registry.createRawSnapshotResponse(cache,options["message-id"]||`persona-feedback-${randomUUID()}`);await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(message));await rm(scratch,{force:true});print({schemaVersion:message.schemaVersion,messageId:message.messageId,documentCount:message.documents.length,out});}
async function readiness(){const reasons=[];let identity,bindingCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){reasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;if(bindingCount===0)reasons.push("bindings:none");}catch(error){reasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){reasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,status:reasons.length===0?"ready":"degraded",reasons,privateStateExposed:false};}
async function status(){print(await readiness());}
function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),cn=nr("cultnet-ts");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,encode:nr("@msgpack/msgpack").encode};}
function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"voidbot"}:${o["producer-runtime-id"]}`;}
function bindingIdFromAdmission(admission){const p=admission.packet;return bindingId({"guild-id":p.discordGuildId,"channel-id":p.discordChannelId,"runtime-id":p.targetRuntimeId,repo:p.targetRepository,"persona-id":p.targetPersonaId,"producer-id":admission.sourceObserverId,"producer-runtime-id":admission.sourceObserverRuntimeId});}
function packetTuple(p){return [p.feedbackId,p.sourceEventId,p.sourceActorId,p.actorClassification,p.actorLinkRefs,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.targetRepository,p.targetPersonaId,p.sourceRoomId,p.feedbackText,p.contentSha256,p.sourceDiscussionRefs,p.sourceVisibility,p.dataClassification,p.privateStateIncluded];}
function admissionSigningTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];}
function parseArgs(args){const result={};for(let i=0;i<args.length;i++){const token=args[i];if(!token.startsWith("--"))throw new Error(`Unexpected ${token}`);result[token.slice(2)]=args[++i];}return result;}
function requireOption(name){const value=options[name];if(!value)throw new Error(`Missing --${name}`);return value;}
function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;}
function shaText(value){return createHash("sha256").update(value,"utf8").digest("hex");}
function shaBytes(value){return createHash("sha256").update(value).digest("hex");}
function stable(value){return JSON.stringify(value,(_key,current)=>current instanceof Uint8Array?Buffer.from(current).toString("hex"):current);}
function positiveInteger(value,label){const parsed=Number(value);if(!Number.isSafeInteger(parsed)||parsed<=0)throw new Error(`${label} must be a positive integer.`);return parsed;}
async function assertStoreSeparation(targetDeliveryStore){const paths=[observationStorePath,privateStorePath,resolve(targetDeliveryStore)],seenPaths=new Set(),seenFiles=new Set();for(const path of paths){const canonical=await canonicalPath(path);const key=process.platform==="win32"?canonical.toLowerCase():canonical;if(seenPaths.has(key))throw new Error("Persona feedback observation inbox, Bifrost private state, and delivery export must use distinct canonical paths.");seenPaths.add(key);try{const info=await stat(path),fileKey=`${info.dev}:${info.ino}`;if(seenFiles.has(fileKey))throw new Error("Persona feedback stores must not alias the same physical file.");seenFiles.add(fileKey);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;}}}
async function canonicalPath(path){try{return await realpath(path);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;return resolve(path);}}
function print(value){process.stdout.write(`${JSON.stringify(value,null,2)}\n`);}

View file

@ -0,0 +1,23 @@
import { openRepositoryReleaseAuthorityStore } from "./bifrost-repository-release-authority.mjs";
const storePath = process.argv[2];
const authorityId = requiredEnvironment("EXPECTED_AUTHORITY_ID");
const commitSha = requiredEnvironment("EXPECTED_COMMIT");
if (!storePath) throw new Error("Pass the repository release-authority CultCache path.");
const store = await openRepositoryReleaseAuthorityStore(storePath);
const authority = store.get(authorityId);
if (!authority) throw new Error(`Bifrost release authority ${authorityId} is absent.`);
if (authority.repositoryFullName !== "GameCult/Bifrost") throw new Error("Release authority does not own GameCult/Bifrost.");
if (authority.upstreamRef !== "refs/heads/main") throw new Error("Release authority does not own refs/heads/main.");
if (authority.commitSha !== commitSha) throw new Error("Release authority does not bind the frozen Bifrost commit.");
if (authority.status !== "authorized" || authority.decision !== "authorize") throw new Error("Release authority is not authorized.");
if (authority.expiresAt && Date.parse(authority.expiresAt) <= Date.now()) throw new Error("Release authority has expired.");
process.stdout.write(`${authority.authorityId}\n`);
function requiredEnvironment(name) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing ${name}.`);
return value;
}