Sign Persona feedback provider health

This commit is contained in:
Meta Vens 2026-07-19 21:41:50 +01:00
commit 671168fefc
6 changed files with 111 additions and 32 deletions

View file

@ -206,6 +206,8 @@ Bifrost admits an observation only when guild, channel, target runtime, canonica
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 same seed also exposes a separately domain-derived `gamecult.provider_health_identity.trust_anchor.v1` through `export-provider-health-anchor` (or `--provider-health-trust-anchor-out` during first enrollment). This works with the already-enrolled service key and creates the public artifact without replacing an existing file. Persona-feedback health uses that provider-health identity, not the feedback host identity. One `serve` process owns one UUID incarnation and a strictly increasing sequence. Each pulse is the exact 17-field `idunn.signed_daemon_health.v1` tuple, signed over its empty-signature canonical MessagePack form with the `gamecult.provider-health.signature.v1` domain and `idunn.signed_daemon_health.v1` purpose. It carries only the closed provider state and a bounded generic reason. The temporary `idunn.daemon_health` dual publication is explicitly tagged and sourced as an unsigned diagnostic; it cannot substitute for signed admission and can be removed after the deployed Idunn trust binding is live.
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.

View file

@ -15,8 +15,9 @@ const {defineDocumentType}=cr("cultcache-ts"), {CultMesh}=mr("cultmesh-ts"), def
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");
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"),providerAnchor=resolve(dir,"provider-health-anchor.msgpack");
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
cliNoStore("export-provider-health-anchor","--private-key",key,"--trust-anchor-out",providerAnchor);
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");
@ -38,6 +39,8 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async(
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 providerTrust=decode(await readFile(providerAnchor));assert.equal(providerTrust.length,6);assert.equal(providerTrust[0],"gamecult.provider_health_identity.trust_anchor.v1");assert.equal(providerTrust[1],createHash("sha256").update(Buffer.from("gamecult.provider-health.identity.v1\0")).update(providerTrust[2]).digest("hex"));assert.notEqual(providerTrust[1],trust[1]);assert.deepEqual(providerTrust[2],trust[2]);
const overwrite=spawnSync(process.execPath,[resolve(root,"tools","persona-feedback.mjs"),"export-provider-health-anchor","--private-key",key,"--trust-anchor-out",providerAnchor],{cwd:root,encoding:"utf8"});assert.notEqual(overwrite.status,0);assert.match(overwrite.stderr,/already exists|EEXIST/);
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);

View file

@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash, createPublicKey, verify } from "node:crypto";
import { createRequire } from "node:module";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { enrollBifrostFeedbackIdentity, providerHealthIdentity, signFeedbackAdmission } from "../tools/bifrost-feedback-identity.mjs";
import { createPersonaFeedbackHealthPublisher, createSignedPersonaFeedbackHealth } from "../tools/persona-feedback-idunn-health.mjs";
const root=resolve(import.meta.dirname,".."),cult=resolve(root,"..","CultLib"),require=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),{encode,decode}=require("@msgpack/msgpack");
const idDomain=Buffer.from("gamecult.provider-health.identity.v1\0"),signatureDomain=Buffer.from("gamecult.provider-health.signature.v1\0"),purpose=Buffer.from("idunn.signed_daemon_health.v1");
test("Persona-feedback emits the exact canonical signed daemon-health tuple",async()=>{
const identity=await identityFixture(),incarnation="019f523f-f650-7001-be75-b4428985652b",signed=createSignedPersonaFeedbackHealth({daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:"active",detail:"ready",identity,publisherIncarnationId:incarnation,publisherSequence:7,observedAtUnixMillis:1773950400123}),record=decode(signed.payload);
assert.equal(record.length,17);assert.deepEqual(record.slice(0,10),["idunn.signed_daemon_health.v1","yggdrasil-bifrost-persona-feedback","bifrost.cultnet-rudp-persona-feedback-health","bifrost-persona-feedback","active","ready",signed.providerIdentityId,incarnation,7,1773950400123]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.equal(record[14],"ed25519");assert.equal(record[15].length,64);assert.equal(record[16],false);
const canonical=[...record];canonical[15]=new Uint8Array();assert.equal(verifyProvider(identity,encode(canonical),record[15]),true);
assert.equal(signed.providerIdentityId,createHash("sha256").update(idDomain).update(identity.publicKey).digest("hex"));assert.notEqual(signed.providerIdentityId,identity.identityId);
});
test("provider-health signature rejects purpose, key, and payload substitution",async()=>{
const identity=await identityFixture(),other=await identityFixture(),signed=createSignedPersonaFeedbackHealth({daemonId:"daemon",healthContract:"contract",state:"degraded",detail:"readiness-degraded",identity,publisherIncarnationId:"019f523f-f650-7001-be75-b4428985652b",publisherSequence:1,observedAtUnixMillis:1773950400123}),record=decode(signed.payload),canonical=[...record];canonical[15]=new Uint8Array();
assert.equal(verifyProvider(identity,encode(canonical),record[15]),true);assert.equal(verifyProvider(other,encode(canonical),record[15]),false);
const mutated=[...canonical];mutated[4]="active";assert.equal(verifyProvider(identity,encode(mutated),record[15]),false);
const oldDomainSignature=signFeedbackAdmission(identity,encode(canonical));assert.equal(verifyProvider(identity,encode(canonical),oldDomainSignature),false);
const unsignedDiagnostic=[record[1],record[4],record[5],new Date(record[9]).toISOString(),record[2],"diagnostic-only","cultnet.transport.rudp.v0"];assert.equal(verifyProvider(identity,encode(unsignedDiagnostic),record[15]),false);
});
test("publisher sequence and process incarnation are mandatory protocol state",async()=>{
const identity=await identityFixture(),base={daemonId:"daemon",healthContract:"contract",state:"warming",detail:"starting",identity,publisherIncarnationId:"019f523f-f650-7001-be75-b4428985652b",publisherSequence:1,observedAtUnixMillis:1773950400123};
for(const bad of [{publisherSequence:0},{publisherSequence:1.5},{publisherIncarnationId:"process-1"},{state:"healthy"},{detail:"private database error"}])assert.throws(()=>createSignedPersonaFeedbackHealth({...base,...bad}));
const publisher=createPersonaFeedbackHealthPublisher(identity,base.publisherIncarnationId),first=publisher.next({daemonId:base.daemonId,healthContract:base.healthContract,state:base.state,detail:base.detail,observedAtUnixMillis:base.observedAtUnixMillis}),second=publisher.next({daemonId:base.daemonId,healthContract:base.healthContract,state:base.state,detail:base.detail,observedAtUnixMillis:base.observedAtUnixMillis+1});assert.equal(decode(first.payload)[7],decode(second.payload)[7]);assert.equal(decode(second.payload)[8],decode(first.payload)[8]+1);
});
async function identityFixture(){const dir=await mkdtemp(resolve(tmpdir(),"bifrost-provider-health-"));return enrollBifrostFeedbackIdentity(resolve(dir,"feedback.seed"));}
function verifyProvider(identity,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(identity.publicKey)]),format:"der",type:"spki"}),message=Buffer.concat([signatureDomain,u64(purpose.length),purpose,u64(payload.length),Buffer.from(payload)]);return verify(null,message,key,Buffer.from(signature));}
function u64(value){const out=Buffer.alloc(8);out.writeBigUInt64BE(BigInt(value));return out;}

View file

@ -4,7 +4,11 @@ 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");
const PROVIDER_HEALTH_ID_DOMAIN=Buffer.from("gamecult.provider-health.identity.v1\0","utf8");
const PROVIDER_HEALTH_SIGNATURE_DOMAIN=Buffer.from("gamecult.provider-health.signature.v1\0","utf8");
export const FEEDBACK_SIGNING_PURPOSE="bifrost.persona-feedback.delivery.v0";
export const IDUNN_SIGNED_DAEMON_HEALTH_PURPOSE="idunn.signed_daemon_health.v1";
export const PROVIDER_HEALTH_TRUST_ANCHOR_SCHEMA="gamecult.provider_health_identity.trust_anchor.v1";
export async function enrollBifrostFeedbackIdentity(privateKeyPath){
await mkdir(dirname(privateKeyPath),{recursive:true});
@ -30,6 +34,17 @@ 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 providerHealthIdentity(identity){
return {...identity,identityId:createHash("sha256").update(PROVIDER_HEALTH_ID_DOMAIN).update(identity.publicKey).digest("hex")};
}
export function signProviderHealth(identity,payload){
const provider=providerHealthIdentity(identity),purpose=Buffer.from(IDUNN_SIGNED_DAEMON_HEALTH_PURPOSE,"utf8"),message=Buffer.concat([PROVIDER_HEALTH_SIGNATURE_DOMAIN,u64(purpose.length),purpose,u64(payload.length),payload]);
return {identityId:provider.identityId,signature:ed25519Sign(null,message,identity.key)};
}
export function providerHealthTrustAnchor(identity,createdAt){
const provider=providerHealthIdentity(identity);
return {schemaVersion:PROVIDER_HEALTH_TRUST_ANCHOR_SCHEMA,identityId:provider.identityId,publicKey:new Uint8Array(identity.publicKey),assurance:"os_service_file_bound",identityCreatedAt:createdAt,sourceIdentityRecordSha256:`sha256-${createHash("sha256").update(PROVIDER_HEALTH_ID_DOMAIN).update(identity.publicKey).digest("hex")}`};
}
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

@ -1,38 +1,57 @@
import dgram from "node:dgram";
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { providerHealthIdentity, signProviderHealth } from "./bifrost-feedback-identity.mjs";
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";
const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),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"),{encode}=require("@msgpack/msgpack");
const protocol="cultnet.transport.rudp.v0",schema="idunn.signed_daemon_health.v1",sourceRuntimeId="bifrost-persona-feedback";
const states=new Set(["active","warming","degraded","failed"]),reasons=new Set(["ready","starting","readiness-degraded","unavailable"]);
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"));
export function createPersonaFeedbackHealthPublisher(identity,publisherIncarnationId=randomUUID()){
if(!identity)throw new Error("Bifrost Persona-feedback provider-health identity is required.");
if(!isUuid(publisherIncarnationId))throw new Error("Bifrost Persona-feedback publisher incarnation must be a UUID.");
let publisherSequence=0;
return {identity,publisherIncarnationId,next(input){return createSignedPersonaFeedbackHealth({...input,identity,publisherIncarnationId,publisherSequence:++publisherSequence});}};
}
export function createSignedPersonaFeedbackHealth(input){
requireText(input.daemonId,"daemon id");requireText(input.healthContract,"health contract");
if(!states.has(input.state))throw new Error(`Bifrost Persona-feedback health state is invalid: ${input.state}`);
if(!reasons.has(input.detail))throw new Error("Bifrost Persona-feedback health detail must be a bounded generic reason.");
if(!input.identity)throw new Error("Bifrost Persona-feedback provider-health identity is required.");
if(!isUuid(input.publisherIncarnationId))throw new Error("Bifrost Persona-feedback publisher incarnation must be a UUID.");
if(!Number.isSafeInteger(input.publisherSequence)||input.publisherSequence<=0)throw new Error("Bifrost Persona-feedback publisher sequence must be a positive safe integer.");
const observed=Number(input.observedAtUnixMillis??Date.now());if(!Number.isSafeInteger(observed)||observed<=0)throw new Error("Bifrost Persona-feedback observation time must be positive Unix milliseconds.");
const provider=providerHealthIdentity(input.identity),record=[schema,input.daemonId,input.healthContract,sourceRuntimeId,input.state,input.detail,provider.identityId,input.publisherIncarnationId,input.publisherSequence,observed,null,null,null,null,"ed25519",new Uint8Array(),false];
const proof=signProviderHealth(input.identity,encode(record));if(proof.identityId!==provider.identityId)throw new Error("Provider-health signer identity derivation disagrees with the packet.");record[15]=new Uint8Array(proof.signature);
return {record,payload:encode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed};
}
export async function publishPersonaFeedbackHealth(input){
if(!input.endpoint)throw new Error("Bifrost Persona-feedback Idunn health endpoint is required.");
if(!input.publisher||typeof input.publisher.next!=="function")throw new Error("Bifrost Persona-feedback process-scoped health publisher is required.");
const endpoint=parseEndpoint(input.endpoint),signed=input.publisher.next(input),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 incarnation=signed.record[7],sequence=signed.record[8],storedAt=new Date(signed.observedAtUnixMillis).toISOString(),signedMessage=rawPut(`bifrost-feedback-signed-health:${incarnation}:${sequence}`,"idunn.signed_daemon_health",input.daemonId,storedAt,signed.payload,"signed-daemon-health-publisher");
await send(socket,endpoint,packet("data",2,encode(encodeCultNetMessageForWire(signedMessage,"cultnet.schema.v0")),"schema"));
if(input.publishUnsignedDiagnostic===true){
const diagnosticPayload=encode([input.daemonId,input.state,input.detail,storedAt,input.healthContract,"diagnostic-only",protocol]),diagnosticMessage=rawPut(`bifrost-feedback-unsigned-diagnostic:${incarnation}:${sequence}`,"idunn.daemon_health",input.daemonId,storedAt,diagnosticPayload,"unsigned-health-diagnostic");
await send(socket,endpoint,packet("data",3,encode(encodeCultNetMessageForWire(diagnosticMessage,"cultnet.schema.v0")),"schema"));
}
await delay(1000);
} finally { socket.close(); }
}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 rawPut(messageId,schemaId,recordKey,storedAt,payload,sourceRole){return {schemaVersion:"cultnet.document_put_raw.v0",messageId,document:{schemaId,recordKey,storedAt,payloadEncoding:"messagepack",payload,sourceRuntimeId,sourceRole,tags:[protocol]}};}
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));}
function requireText(value,label){if(typeof value!=="string"||!value.trim()||value.length>256||/[\u0000-\u001f\u007f]/.test(value))throw new Error(`Bifrost Persona-feedback ${label} is invalid.`);}
function isUuid(value){return typeof value==="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);}

View file

@ -5,8 +5,8 @@ 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";
import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs";
import { createPersonaFeedbackHealthPublisher, 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);
@ -24,8 +24,9 @@ async function main(){
if(command==="serve")return serve();
if(command==="export")return exportSnapshot();
if(command==="enroll-identity")return enrollIdentity();
if(command==="export-provider-health-anchor")return exportProviderHealthAnchor();
if(command==="status")return status();
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|status|export|enroll-identity");
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|status|export|enroll-identity|export-provider-health-anchor");
}
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]});}
@ -52,9 +53,10 @@ async function processEvent(id,emit=true){
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 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.");const healthPublisher=healthEndpoint?createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()))):null;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"?"active":"degraded",detail:readinessState.status==="ready"?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});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 enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),createdAt=new Date().toISOString(),anchor=trustAnchor(identity,createdAt),out=resolve(requireOption("trust-anchor-out")),providerOut=options["provider-health-trust-anchor-out"]?resolve(options["provider-health-trust-anchor-out"]):null;await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)));if(providerOut){if(providerOut===out)throw new Error("Feedback and provider-health trust anchors must use distinct paths.");const providerAnchor=providerHealthTrustAnchor(identity,createdAt);await mkdir(dirname(providerOut),{recursive:true});await writeFile(providerOut,runtime.encode(anchorTuple(providerAnchor)));}print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId,providerHealthTrustAnchor:providerOut??""});}
async function exportProviderHealthAnchor(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await loadBifrostFeedbackIdentity(keyPath),out=resolve(requireOption("trust-anchor-out")),anchor=providerHealthTrustAnchor(identity,new Date().toISOString());await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)),{flag:"wx",mode:0o640});print({privateKey:keyPath,trustAnchor:out,identityId:anchor.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());}
@ -64,6 +66,7 @@ function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["
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 anchorTuple(a){return [a.schemaVersion,a.identityId,a.publicKey,a.assurance,a.identityCreatedAt,a.sourceIdentityRecordSha256];}
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;}